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 001/187] 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 002/187] 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 003/187] 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 004/187] 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 005/187] 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 006/187] 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 007/187] 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 008/187] 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 009/187] 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 010/187] 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 011/187] 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 012/187] 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 013/187] 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 014/187] 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 015/187] 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 016/187] 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 017/187] 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 018/187] 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 019/187] 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 020/187] 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 021/187] 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 022/187] 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 023/187] 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 024/187] 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 025/187] 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 026/187] 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 027/187] 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 028/187] 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 029/187] 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 030/187] 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 031/187] 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 032/187] 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 10a6a27413d23e233753ddc6b920cba89cf056df Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:48:42 +0200 Subject: [PATCH 033/187] Add owned final-output hooks with scoped GPU state --- docs/api3-portability.md | 6 +- docs/render-output.md | 48 ++++++ src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 +- src/render/GraphicsScope.lua | 50 ++++++ src/render/Output.lua | 37 ++++ src/render/Renderer.lua | 14 +- .../api3_contract/mods/render_output/main.lua | 48 ++++++ .../mods/render_output/manifest.json | 1 + tests/api3_contract/render_output.lua | 49 ++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/render_output.lua | 81 +++++++++ tools/api3-contract-check/check.py | 2 +- tools/render-output-check/conf.lua | 7 + tools/render-output-check/main.lua | 161 ++++++++++++++++++ 15 files changed, 505 insertions(+), 8 deletions(-) create mode 100644 docs/render-output.md create mode 100644 src/render/GraphicsScope.lua create mode 100644 src/render/Output.lua create mode 100644 tests/api3_contract/mods/render_output/main.lua create mode 100644 tests/api3_contract/mods/render_output/manifest.json create mode 100644 tests/api3_contract/render_output.lua create mode 100644 tests/engine/render_output.lua create mode 100644 tools/render-output-check/conf.lua create mode 100644 tools/render-output-check/main.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 689c217a8..4bdc30f13 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -133,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 184 comparisons -and 368 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 192 comparisons +and 384 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 @@ -157,3 +157,5 @@ 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. + +Final composed-frame ownership is documented in [render-output.md](render-output.md). diff --git a/docs/render-output.md b/docs/render-output.md new file mode 100644 index 000000000..d06192402 --- /dev/null +++ b/docs/render-output.md @@ -0,0 +1,48 @@ +# Final render output for mods + +API-3 mods can take ownership of the normally composed frame: + +```lua +mod.hooks:wrap("render.output_enabled", function(next) + return enabled or next() +end) +mod.hooks:wrap("render.output", function(next, ctx) + if not enabled then return next(ctx) end + -- Draw ctx.canvas through the mod's graphics/presentation service. + return true +end) +``` + +`render.output_enabled` has no arguments and defaults to `false`. Capture is +allocated only when an output subscriber exists and the predicate returns +exactly `true`. With neither an output owner nor another present-stage effect, +the renderer retains its direct path and allocates no present canvas. + +`render.output(ctx)` receives the finished composition after native +post-process pipelines and before optional display effects. Its context is +`{ canvas, width, height, gameX, gameY, gameWidth, gameHeight, scale, dpiX, +dpiY, generation }`. Sizes and positions are LÖVE units; `scale` is native +framebuffer pixels per game pixel. The canvas is borrowed for that frame and +must not be released or retained across resize/context loss. + +Returning exactly `true` takes ownership of presentation. Other results fall +back to the ordinary display path. An earlier `render.compose` owner bypasses +normal composition and this output seam, as in the reference. The output +seam does not take over HUD/mobile control drawing. A disabled/unloaded owner +cannot force capture through a predicate alone. + +Callbacks begin with an identity transform, no shader/scissor, ordinary alpha +blend and white color. GPU state and the render target are restored afterward, +including when a callback throws or leaks graphics pushes. A callback cannot +pop the caller's graphics state through the scoped stack API. The same fence +protects the predicate. Existing hook-chain error/next semantics still apply. +A callback that returns `true` is responsible for drawing a complete output. + +The public hook contract is available on the host's common renderer across +its generations. Shared fixtures compare the same API-3 mod on eight Gen1–3 +versions against pinned Gen1Recomp using explicit canvas/dispatch seams. +`love tools/render-output-check` additionally drives the actual Renderer and +public mod with real GPU draws and screenshot readback: disabled direct path, +false fallback, identity ownership, inversion with clean HUD state, callback +failure, nonboolean predicate and owner removal. No ROM is required and the +probe writes no game settings. diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 32da142d7..ffbd31431 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -20,7 +20,7 @@ add("hooks", "12345", [[ encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.step link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite - render.compose render.hud render.letterbox render.zones save.new_game save.write + render.compose render.hud render.letterbox render.output render.output_enabled 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 ]]) diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 5e6a6195d..ca4ae0034 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -21,7 +21,7 @@ C.hooks = names([[ encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud - render.letterbox render.zones save.new_game save.write script.command trainer.party transition.style + render.letterbox render.output render.output_enabled 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 ]]) diff --git a/src/render/GraphicsScope.lua b/src/render/GraphicsScope.lua new file mode 100644 index 000000000..931fe14c6 --- /dev/null +++ b/src/render/GraphicsScope.lua @@ -0,0 +1,50 @@ +-- Fence a native render seam, including leaked pushes and canvas changes. +local S = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +function S.call(fn, ...) + local G = love.graphics + local canvas = pack(G.getCanvas()) + local realPush, realPop = G.push, G.pop + local before = G.getStackDepth and G.getStackDepth() + local pushed, why = pcall(realPush, "all") + if not pushed then + return false, why + end + local depth = 0 + G.push = function(...) + local result = pack(realPush(...)) + depth = depth + 1 + return unpack(result, 1, result.n) + end + G.pop = function(...) + if depth <= 0 then + error("render callback cannot pop caller graphics state", 0) + end + local result = pack(realPop(...)) + depth = depth - 1 + return unpack(result, 1, result.n) + end + local out = pack(pcall(fn, ...)) + G.push, G.pop = realPush, realPop + if before and G.getStackDepth then + local current = G.getStackDepth() + if current < before + 1 then + G.setCanvas(unpack(canvas, 1, canvas.n)) + return false, "render callback removed its graphics guard" + end + depth = current - before - 1 + end + for _ = 1, depth do + pcall(realPop) + end + local restored, err = pcall(realPop) + G.setCanvas(unpack(canvas, 1, canvas.n)) + if not restored then + return false, err + end + return unpack(out, 1, out.n) +end +return S diff --git a/src/render/Output.lua b/src/render/Output.lua new file mode 100644 index 000000000..16a9237a0 --- /dev/null +++ b/src/render/Output.lua @@ -0,0 +1,37 @@ +-- Final-output ownership comes after composition and before display effects. +local Runtime = require("src.mods.Runtime") +local Scope = require("src.render.GraphicsScope") +local Logger = require("src.core.Logger") +local O = {} +function O.enabled() + if not Runtime.wantsHook("render.output") then + return false + end + local ok, value = Scope.call(function() + return Runtime.call("render.output_enabled", function() + return false + end) + end) + if not ok then + Logger.warn("render output gate failed: %s", tostring(value)) + end + return ok and value == true +end +function O.draw(ctx) + local ok, value = Scope.call(function() + local G = love.graphics + G.origin() + G.setShader() + G.setScissor() + G.setBlendMode("alpha") + G.setColor(1, 1, 1, 1) + return Runtime.call("render.output", function() + return false + end, ctx) + end) + if not ok then + Logger.warn("render output failed: %s", tostring(value)) + end + return ok and value == true +end +return O diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7cff87285..59eeda645 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") +local Output = require("src.render.Output") -- RESOLVED ONCE, NOT EVERY FRAME. These three were `require`d from inside -- beginFrame and endFrame -- three string hashes into package.loaded on every @@ -929,8 +930,9 @@ function Renderer:endFrame(zones, worldZones) and (self.battleWipe.style == "frlg_swirl" or self.battleWipe.needsSource) and self.battleWipe.screenDraw + local hasOutputHook = Output.enabled() local needPresent = GBCFX.active() or Pipelines.wantsPresent() - or battleNeedsSource + or battleNeedsSource or hasOutputHook local present = nil if needPresent then if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww @@ -1401,7 +1403,15 @@ function Renderer:endFrame(zones, worldZones) -- this returns `present` unchanged and the frame is byte-identical. local composed = Pipelines.present(present, { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present - if GBCFX.active() then + local outputHandled = hasOutputHook and Output.draw({ + canvas = composed, width = ww, height = wh, + gameX = ox, gameY = oy, gameWidth = vpw, gameHeight = vph, + scale = Sp, dpiX = dpiX, dpiY = dpiY, + generation = require("src.core.GameVersion").generation(), + }) + if outputHandled then + -- The enabled owner presented the composed frame. + elseif GBCFX.active() then -- shader grid/shadow math is in framebuffer pixels GBCFX.present(composed, Sp) else diff --git a/tests/api3_contract/mods/render_output/main.lua b/tests/api3_contract/mods/render_output/main.lua new file mode 100644 index 000000000..0882775cd --- /dev/null +++ b/tests/api3_contract/mods/render_output/main.lua @@ -0,0 +1,48 @@ +local mod = ... +assert(mod:supports("hooks", "render.output")) +assert(mod:supports("hooks", "render.output_enabled")) +local enabled, handled, callback, gate = false, false, nil, nil +local trace = {} +mod.hooks:wrap("render.output_enabled", function(next) + if gate then + return gate() + end + if enabled then + return true + end + return next() +end) +mod.hooks:wrap("render.output", function(next, ctx) + assert(ctx.canvas and ctx.width == 320 and ctx.height == 288 and ctx.generation == mod.generation) + trace[#trace + 1] = { + width = ctx.width, + height = ctx.height, + gameWidth = ctx.gameWidth, + gameHeight = ctx.gameHeight, + gameX = ctx.gameX, + gameY = ctx.gameY, + scale = ctx.scale, + dpiX = ctx.dpiX, + dpiY = ctx.dpiY, + generation = ctx.generation, + } + if callback then + return callback(ctx) + end + if handled then + return true + end + return next(ctx) +end) +mod.exports.state = function(on, owns) + enabled, handled = on, owns +end +mod.exports.callback = function(fn) + callback = fn +end +mod.exports.gate = function(fn) + gate = fn +end +mod.exports.trace = function() + return trace +end diff --git a/tests/api3_contract/mods/render_output/manifest.json b/tests/api3_contract/mods/render_output/manifest.json new file mode 100644 index 000000000..3a2878ae7 --- /dev/null +++ b/tests/api3_contract/mods/render_output/manifest.json @@ -0,0 +1 @@ +{"id":"render_output","name":"Render output contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/render_output.lua b/tests/api3_contract/render_output.lua new file mode 100644 index 000000000..8e3dfce54 --- /dev/null +++ b/tests/api3_contract/render_output.lua @@ -0,0 +1,49 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/render_output%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "render_output") + love = love or require("tests.love_stub") + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local Runtime = require("src.mods.Runtime") + local Output = backend == "gen2" and require("src.render.Output") + or { + enabled = function() + return Runtime.wantsHook("render.output") + and Runtime.call("render.output_enabled", function() + return false + end) == true + end, + draw = function(ctx) + return Runtime.call("render.output", function() + return false + end, ctx) == true + end, + } + assert(not Output.enabled()) + local ctx = { + canvas = newproxy(true), + width = 320, + height = 288, + gameX = 0, + gameY = 0, + gameWidth = 320, + gameHeight = 288, + scale = 2, + dpiX = 1, + dpiY = 1, + generation = f.gen, + } + f.exports.state(true, true) + assert(Output.enabled() and Output.draw(ctx)) + f.exports.state(true, false) + assert(Output.enabled() and not Output.draw(ctx)) + f.exports.state(false, true) + assert(not Output.enabled()) + local report = { trace = f.exports.trace(), disabled = true, fallback = 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 8aab2b5e6..0d24b2bbd 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -30,11 +30,14 @@ assert( or mode == "script_fields" or mode == "effects" or mode == "datasets" + or mode == "render_output" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "datasets" then +if mode == "render_output" then + dofile(codeRoot .. "/render_output.lua").run(root, backend, version) +elseif mode == "datasets" then dofile(codeRoot .. "/datasets.lua").run(root, backend, version) elseif mode == "effects" then dofile(codeRoot .. "/effects.lua").run(root, backend, version) diff --git a/tests/engine/render_output.lua b/tests/engine/render_output.lua new file mode 100644 index 000000000..d8afd8ffd --- /dev/null +++ b/tests/engine/render_output.lua @@ -0,0 +1,81 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.render_output") +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 Output = require("src.render.Output") +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 .. " output boundary contract: " .. tostring(why)) +end +for _, mode in ipairs({ "dirty", "throw", "leak", "overpop" }) do + local f = Contract.fixture("tests/api3_contract", "gen2", "red") + local G = love.graphics + local previous, foreign, shader = {}, {}, {} + G.setCanvas(previous) + G.setShader(shader) + G.setColor(0.2, 0.3, 0.4, 0.5) + G.setBlendMode("add") + local originalPush, originalPop = G.push, G.pop + f.exports.state(true, true) + f.exports.callback(function() + G.setShader({}) + G.setCanvas(foreign) + G.setColor(1, 0, 0, 1) + G.setBlendMode("replace") + if mode == "throw" then + error("injected output failure") + end + if mode == "leak" then + G.push("all") + G.push("all") + end + if mode == "overpop" then + G.pop() + end + return true + end) + local ctx = { + canvas = {}, + width = 320, + height = 288, + gameX = 0, + gameY = 0, + gameWidth = 320, + gameHeight = 288, + scale = 2, + dpiX = 1, + dpiY = 1, + generation = 1, + } + T.eq(Output.enabled(), true, "enabled output owner") + T.eq(Output.draw(ctx), mode == "dirty" or mode == "leak", "error/overpop falls back") + T.eq(G.getCanvas(), previous, "canvas restored after " .. mode) + T.eq(G.getShader(), shader, "shader restored after " .. mode) + T.eq(G.getBlendMode(), "add", "blend restored after " .. mode) + T.eq(select(1, G.getColor()), 0.2, "color restored after " .. mode) + T.eq(G.push, originalPush, "push wrapper removed after " .. mode) + T.eq(G.pop, originalPop, "pop wrapper removed after " .. mode) + f.exports.gate(function() + G.setCanvas(foreign) + G.setShader({}) + return 1 + end) + T.eq(Output.enabled(), false, "only exact true enables capture") + T.eq(G.getCanvas(), previous, "predicate canvas restored") + T.eq(G.getShader(), shader, "predicate shader restored") + local mod = f.loader.apis.render_output + mod.hooks:wrap("render.output_enabled", function(next) + return next() + end, -10) + mod.hooks:wrap("render.output", function(next, frame) + return next(frame) + end, -10) + f.loader:_rollback("render_output") + T.eq(Output.enabled(), false, "unloaded owner no longer captures output") +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 e16316854..4f23570f5 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", "datasets"): + 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", "render_output"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures diff --git a/tools/render-output-check/conf.lua b/tools/render-output-check/conf.lua new file mode 100644 index 000000000..cb7f5b452 --- /dev/null +++ b/tools/render-output-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity = "gen2-render-output-check" + t.window.title = "Gen2Recomped output contract GPU check" + t.window.width, t.window.height = 320, 288 + t.window.highdpi = true + t.modules.audio = false +end diff --git a/tools/render-output-check/main.lua b/tools/render-output-check/main.lua new file mode 100644 index 000000000..bd34d442f --- /dev/null +++ b/tools/render-output-check/main.lua @@ -0,0 +1,161 @@ +-- Actual Renderer, public API-3 mod, real GL draw/readback. No ROM or settings. +local root = love.filesystem.getSource():match("^(.*)/tools/render%-output%-check$") + or love.filesystem.getWorkingDirectory() +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +love.filesystem.mount(root, "", true) +local Contract = require("tests.api3_contract.render_output") +local Renderer = require("src.render.Renderer") +local Palette = require("src.render.PaletteFX") +local GBCFX = require("src.render.GBCFX") +local checks, phase, waiting = 0, 1, false +local baseline, fixture, shader, foreign +local function check(ok, message) + checks = checks + 1 + assert(ok, message) +end +local function near(a, b) + return math.abs(a - b) < 1.1 / 255 +end +local function compare(pixels, inverse, hud) + check( + pixels:getWidth() == baseline:getWidth() and pixels:getHeight() == baseline:getHeight(), + "frame dimensions" + ) + local pw, ph = pixels:getDimensions() + for y = 0, ph - 1 do + for x = 0, pw - 1 do + local a, b = { pixels:getPixel(x, y) }, { baseline:getPixel(x, y) } + if hud and x < 20 * (pw / 320) and y < 20 * (ph / 288) then + b = { 0, 1, 0, 1 } + elseif inverse then + for i = 1, 3 do + b[i] = 1 - b[i] + end + end + for i = 1, 4 do + check(near(a[i], b[i]), "GPU pixel " .. x .. "," .. y .. " channel " .. i .. " phase " .. phase) + end + end + end +end +local function scene() + Renderer:setUISize(160, 144) + Renderer:beginFrame() + local G = love.graphics + G.clear(1, 1, 1, 1) + G.setColor(0.2, 0.2, 0.2, 1) + G.rectangle("fill", 0, 0, 80, 72) + G.setColor(0.7, 0.7, 0.7, 1) + G.rectangle("fill", 80, 72, 80, 72) + G.setColor(1, 1, 1, 1) +end +function love.load() + local ok, why = xpcall(function() + Renderer:init() + Palette.setMode("dmg") + GBCFX.setLevel(0) + fixture = Contract.fixture(root .. "/tests/api3_contract", "gen2", "red") + shader = love.graphics.newShader( + [[vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){vec4 s=Texel(t,uv);return vec4(vec3(1.0)-s.rgb,s.a)*c;}]] + ) + foreign = love.graphics.newCanvas(4, 4) + end, debug.traceback) + if not ok then + io.stderr:write(why .. "\n") + love.event.quit(1) + end +end +function love.draw() + if waiting then + return + end + waiting = true + local ok, why = xpcall(function() + fixture.exports.state(phase ~= 1 and phase ~= 7, true) + fixture.exports.gate(nil) + fixture.exports.callback(nil) + if phase == 2 then + fixture.exports.state(true, false) + end + if phase == 3 then + fixture.exports.callback(function(ctx) + love.graphics.draw(ctx.canvas) + return true + end) + end + if phase == 4 then + fixture.exports.callback(function(ctx) + local G = love.graphics + G.setShader(shader) + G.draw(ctx.canvas) + G.push("all") + G.translate(30, 40) + G.setCanvas(foreign) + G.setColor(1, 0, 0, 1) + G.setBlendMode("add") + return true + end) + end + if phase == 5 then + fixture.exports.callback(function() + local G = love.graphics + G.setCanvas(foreign) + G.setShader(shader) + G.translate(70, 90) + error("injected output draw failure") + end) + end + if phase == 6 then + fixture.exports.gate(function() + love.graphics.setShader(shader) + love.graphics.setCanvas(foreign) + return 1 + end) + end + if phase == 7 then + fixture.loader:_rollback("render_output") + end + scene() + local depth = love.graphics.getStackDepth() + Renderer:endFrame() + check(love.graphics.getCanvas() == nil, "frame target restored") + check(love.graphics.getShader() == nil, "shader restored") + check(love.graphics.getStackDepth() == depth, "graphics stack balanced") + local x, y = love.graphics.transformPoint(0, 0) + check(x == 0 and y == 0, "transform restored") + if phase == 1 then + check(Renderer.presentCanvas == nil, "disabled owner creates no present target") + end + if phase == 4 then + love.graphics.setColor(0, 1, 0, 1) + love.graphics.rectangle("fill", 0, 0, 20, 20) + love.graphics.setColor(1, 1, 1, 1) + end + love.graphics.captureScreenshot(function(pixels) + local good, err = xpcall(function() + if phase == 1 then + baseline = pixels + else + compare(pixels, phase == 4, phase == 4) + pixels:release() + end + phase = phase + 1 + if phase == 8 then + print("RENDER OUTPUT GPU: " .. checks .. " checks passed") + local a, b, c, d = love.graphics.getRendererInfo() + print(table.concat({ a, b, c, d }, " | ")) + love.event.quit(0) + end + waiting = false + end, debug.traceback) + if not good then + io.stderr:write(err .. "\n") + love.event.quit(1) + end + end) + end, debug.traceback) + if not ok then + io.stderr:write(why .. "\n") + love.event.quit(1) + end +end From 0481a7cb72e2850b83505a7c45fec8e8f2cf54c7 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:00:13 +0200 Subject: [PATCH 034/187] Update output capability assertion and fail GPU probe promptly --- tests/engine/api3_compat.lua | 2 +- tools/render-output-check/main.lua | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index 83d92fc1b..e028c128d 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -156,7 +156,7 @@ do 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.eq(m:supports("hooks", "render.output"), true, "implemented output hook is available") T.check( pcall(function() m.hooks:wrap("render.output", function(next, ctx) diff --git a/tools/render-output-check/main.lua b/tools/render-output-check/main.lua index bd34d442f..b3265808f 100644 --- a/tools/render-output-check/main.lua +++ b/tools/render-output-check/main.lua @@ -1,4 +1,8 @@ -- Actual Renderer, public API-3 mod, real GL draw/readback. No ROM or settings. +function love.errorhandler(message) + io.stderr:write(tostring(message).."\n"..debug.traceback().."\n") + return function() love.event.quit(1) end +end local root = love.filesystem.getSource():match("^(.*)/tools/render%-output%-check$") or love.filesystem.getWorkingDirectory() package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path From ce4a825a961d35df2e6748b22632843f77d5c671 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:11:48 +0200 Subject: [PATCH 035/187] Add game viewport and window composition contracts --- docs/api3-portability.md | 6 +- docs/game-viewport.md | 65 +++++ docs/render-output.md | 3 + main.lua | 6 +- src/core/Game.lua | 56 +++- src/core/SafeArea.lua | 26 +- src/core/TouchControls.lua | 6 +- src/mods/Capabilities.lua | 4 +- src/mods/Loader.lua | 4 + src/mods/UnifiedCallbacks.lua | 4 +- src/render/GameViewport.lua | 241 ++++++++++++++++ src/render/Renderer.lua | 11 +- tests/api3_contract/mods/viewport/main.lua | 68 +++++ .../api3_contract/mods/viewport/manifest.json | 1 + tests/api3_contract/viewport.lua | 48 ++++ tests/api3_contract/worker.lua | 5 +- tests/engine/game_viewport.lua | 258 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- tools/game-viewport-check/conf.lua | 7 + tools/game-viewport-check/main.lua | 210 ++++++++++++++ 20 files changed, 999 insertions(+), 32 deletions(-) create mode 100644 docs/game-viewport.md create mode 100644 src/render/GameViewport.lua create mode 100644 tests/api3_contract/mods/viewport/main.lua create mode 100644 tests/api3_contract/mods/viewport/manifest.json create mode 100644 tests/api3_contract/viewport.lua create mode 100644 tests/engine/game_viewport.lua create mode 100644 tools/game-viewport-check/conf.lua create mode 100644 tools/game-viewport-check/main.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 4bdc30f13..e5d44665b 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -133,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 192 comparisons -and 384 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 200 comparisons +and 400 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 @@ -159,3 +159,5 @@ whenever another API-3 service becomes portable; do not make a failing comparison pass by changing only one engine's mod fixture. Final composed-frame ownership is documented in [render-output.md](render-output.md). + +Viewport reservation, window composition and pointer coordinates are documented in [game-viewport.md](game-viewport.md). diff --git a/docs/game-viewport.md b/docs/game-viewport.md new file mode 100644 index 000000000..83d8fcc8d --- /dev/null +++ b/docs/game-viewport.md @@ -0,0 +1,65 @@ +# Game viewport and final window composition + +API-3 layout mods can reserve an OS-window rectangle for the normally rendered +Gen1–3 game, then draw their surrounding UI through a shared window hook: + +```lua +mod.hooks:wrap("render.viewport", function(next, metrics) + return { x = 0, y = 0, width = metrics.width / 2, height = metrics.height } +end) +mod.hooks:wrap("render.window", function(next, game, ctx) + -- Draw window-space chrome using the mod's graphics service. + return next(game, ctx) +end) +``` + +Viewport metrics are `{ width, height, pixelWidth, pixelHeight, dpiX, dpiY, +generation }`. Rectangle `x`, `y`, `width`, `height` are LÖVE units. Finite +values are floored and clamped to the real window, with at least one unit in +each axis. A plain rectangle table is required; unexpected fields are ignored. +Returning the whole window preserves the direct path. `capture=true` requests +a separate target even at full size. No canvas is allocated without capture or +a changed rectangle. Changing position reuses a same-size target; resizing, +disabling and retiring a view release its old canvas. Failed allocations fall +back to the ordinary full game and retry after a changed request. + +The normal Renderer uses the rectangle's own dimensions, pixel dimensions and +DPI. World/UI composition, HUD and final-output owners draw inside it. +`render.window(game, ctx)` then receives `{ canvas, x, y, width, height, +windowWidth, windowHeight, dpiX, dpiY, generation }`. Its default draws the +borrowed game canvas at `x,y`; the OS window starts black. The hook runs only +when a viewport target is active. It follows ordinary hook/next semantics, +without a separate boolean ownership result. Mobile controls draw afterward +in OS-window coordinates, at full size. + +Both hooks fence GPU state. The default window draw also fences its own state +so a failed outer hook cannot redirect it to another canvas or shader. +Invalid downstream contexts fall back to the original frame. A reset during +composition defers target release until the frame finishes. Mod reload, +launcher teardown and renderer reinitialization clear viewport state. +Gen4/5 and native combined-screen layouts keep their existing display path; +`mod:supports("hooks", "render.viewport")`/`"render.window"` report only Gen1–3. + +The existing touch/mouse-as-touch route now supplies `input.pointer` with +`{ phase, source, id, x, y, gameX, gameY, insideGame, dx, dy, pressure, button }`. +Phases are `pressed`, `moved`, `released`. Window `x,y` remain unchanged; +`gameX,gameY` subtract the viewport origin, and `insideGame` tests its bounds. +Unspecified movement deltas derive from the last point. Real touch pressure +is forwarded. This is the existing main-window press/drag route, not a new +hover, wheel or multitouch input system. + +Virtual controls keep their complete gesture first. Mods can claim other +pointer gestures by returning exactly `true`. Once a native screen has +captured a gesture, a later mod movement result cannot steal its release. +Native game-screen handlers receive local coordinates; injected secondary +screen events and OS-window controls keep their existing coordinates. An +outside press cannot activate a game screen through the reserved margin. + +`love tools/game-viewport-check` drives actual `Game:draw`, the Renderer and the +same public API-3 layout mod with real screenshot readback on Red, Gold and +FireRed. It verifies default/captured paths, window chrome, compositor failure, +deferred retirement, resize, allocation fallback, state restoration and +unscaled controls. Shared eight-version comparisons additionally check hook +payloads and pointer coordinates against pinned Gen1Recomp. No ROM is required +and no game settings are written. Native combined-screen/mobile hardware and +ROM gameplay are not exercised by this desktop GPU probe. diff --git a/docs/render-output.md b/docs/render-output.md index d06192402..a5995b5cb 100644 --- a/docs/render-output.md +++ b/docs/render-output.md @@ -46,3 +46,6 @@ public mod with real GPU draws and screenshot readback: disabled direct path, false fallback, identity ownership, inversion with clean HUD state, callback failure, nonboolean predicate and owner removal. No ROM is required and the probe writes no game settings. + +With a [game viewport](game-viewport.md), output metrics refer to that game rectangle. +Window composition and physical controls follow the output stage. diff --git a/main.lua b/main.lua index 18325ffdf..15d0c1b6e 100644 --- a/main.lua +++ b/main.lua @@ -892,7 +892,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure) end if Importer then return Importer:touchpressed(id,x,y,dx,dy,pressure) end if not Game then return end - Game:touchpressed(id, x, y) + Game:touchpressed(id, x, y, dx, dy, pressure) end function love.touchmoved(id, x, y, dx, dy, pressure) @@ -906,7 +906,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure) end if Importer then return Importer:touchmoved(id,x,y,dx,dy,pressure) end if not Game then return end - Game:touchmoved(id, x, y) + Game:touchmoved(id, x, y, dx, dy, pressure) end function love.touchreleased(id, x, y, dx, dy, pressure) @@ -920,7 +920,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure) end if Importer then return Importer:touchreleased(id,x,y,dx,dy,pressure) end if not Game then return end - Game:touchreleased(id, x, y) + Game:touchreleased(id, x, y, dx, dy, pressure) end function love.wheelmoved(x, y) diff --git a/src/core/Game.lua b/src/core/Game.lua index 9dc07ce9d..ef00d1af3 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -7,6 +7,7 @@ local Input = require("src.core.Input") local Logger = require("src.core.Logger") local Platform = require("src.core.Platform") local Renderer = require("src.render.Renderer") +local GameViewport = require("src.render.GameViewport") local SaveData = require("src.core.SaveData") local StateStack = require("src.core.StateStack") local FrameProfile = require("src.core.FrameProfile") @@ -30,6 +31,7 @@ end function Game:load() self.pointerOwners={} + self.modPointers={} self.secondScreenPointerOwner=nil self.secondScreenOverworldPoketch=nil self.data = Data @@ -917,6 +919,8 @@ Game.centerClassicZones = centerClassicZones function Game:draw() local closeDraw = FrameProfile.section("draw: whole frame") local screenLayout=require('src.ui.SecondScreen') + local generation = require('src.core.GameVersion').generation() + GameViewport.begin(generation, generation <= 3 and not screenLayout.combined(self)) Renderer.splitViewport=nil if screenLayout.combined(self) then local w,h=love.graphics.getDimensions() @@ -979,8 +983,9 @@ function Game:draw() if screenLayout.combined(self) then screenLayout.composeWindow(self) - TouchControls:draw() end + GameViewport.finish(self) + TouchControls:draw() closeDraw() -- THE SECOND PANEL'S FRAME LEAVES LAST. -- @@ -1176,7 +1181,7 @@ function Game:_draw() ModRuntime.call("render.hud", function() end, self, viewport) end -- on-screen mobile controls: pure screen-space, over the finished frame - if not Renderer.splitViewport then TouchControls:draw() end + -- OS-window controls draw after GameViewport.finish in Game:draw. end -- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out @@ -1710,9 +1715,13 @@ end local function offerPointer(self, method, id, x, y) self.pointerOwners=self.pointerOwners or {} local captured=self.pointerOwners[id] + local gameX, gameY, inside = GameViewport.toLocal(x, y) + local panel = self.secondScreenPointerOwner local function offer(screen) if not (screen and type(screen[method])=='function') then return false end - local ok,claimed=pcall(screen[method],screen,id,x,y) + local ok,claimed=pcall(screen[method],screen,id, + (self.secondScreenInjecting or screen == panel) and x or gameX, + (self.secondScreenInjecting or screen == panel) and y or gameY) if not ok then require('src.core.Logger').warn('%s raised in a touch handler: %s', tostring(screen.name or 'a screen'),tostring(claimed)) @@ -1727,14 +1736,42 @@ local function offerPointer(self, method, id, x, y) return true end local top = self.stack and self.stack.top and self.stack:top() - if offer(top) then return true end + if (inside or self.secondScreenInjecting) and offer(top) then return true end -- The always-present Android lower-panel watch is drawn outside the stack. -- Its coordinate mapper rejects main-window input in display mode. - local panel=self.secondScreenPointerOwner return panel~=top and offer(panel) or false end -function Game:touchpressed(id, x, y) +-- Window-space pointer metadata plus coordinates local to the game rectangle. +local function pointerUnclaimed() return false end +function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button) + local gameX, gameY, inside = GameViewport.toLocal(x, y) + return ModRuntime.call("input.pointer", pointerUnclaimed, self, { + phase = phase, source = source, id = id, x = x, y = y, + gameX = gameX, gameY = gameY, insideGame = inside, + dx = dx or 0, dy = dy or 0, pressure = pressure, button = button, + }) == true +end +local function modPointer(self, phase, id, x, y, dx, dy, pressure) + if self.secondScreenInjecting then return false end + local p = self.modPointers and self.modPointers[id] + if phase == "pressed" then + if not ModRuntime.wantsHook("input.pointer") then return false end + self.modPointers = self.modPointers or {} + p = { source = id == "mouse" and "mouse" or "touch", x = x, y = y } + self.modPointers[id] = p + elseif not p then return false end + dx, dy = dx or (x - p.x), dy or (y - p.y) + p.x, p.y = x, y + if pressure ~= nil then p.pressure = pressure end + local nativeOwner = self.pointerOwners and self.pointerOwners[id] + local claimed = self:pointerEvent(phase, p.source, id, x, y, dx, dy, p.pressure, id == "mouse" and 1 or nil) + if not nativeOwner and claimed then p.claimed = true end + if phase == "released" and self.modPointers then self.modPointers[id] = nil end + return not nativeOwner and p.claimed == true +end + +function Game:touchpressed(id, x, y, dx, dy, pressure) -- Visible virtual controls own their complete gesture, even when pressing -- one opens a different screen before the finger is lifted. if not self.secondScreenInjecting and TouchControls.visible and TouchControls:visible() @@ -1742,6 +1779,7 @@ function Game:touchpressed(id, x, y) TouchControls:touchpressed(id,x,y) return end + if modPointer(self, "pressed", id, x, y, dx, dy, pressure) then return end if offerPointer(self, "touchpressed", id, x, y) then return end if self.secondScreenInjecting then return end TouchControls:touchpressed(id, x, y) @@ -1756,21 +1794,23 @@ function Game:hasPointerScreen() return top and type(top.touchpressed)=='function' or false end -function Game:touchmoved(id, x, y) +function Game:touchmoved(id, x, y, dx, dy, pressure) if not self.secondScreenInjecting and TouchControls.touches and TouchControls.touches[id] then TouchControls:touchmoved(id,x,y) return end + if modPointer(self, "moved", id, x, y, dx, dy, pressure) then return end if offerPointer(self, "touchmoved", id, x, y) then return end if self.secondScreenInjecting then return end TouchControls:touchmoved(id, x, y) end -function Game:touchreleased(id, x, y) +function Game:touchreleased(id, x, y, dx, dy, pressure) if not self.secondScreenInjecting and TouchControls.touches and TouchControls.touches[id] then TouchControls:touchreleased(id,x,y) return end + if modPointer(self, "released", id, x, y, dx, dy, pressure) then return end if offerPointer(self, "touchreleased", id, x, y) then return end if self.secondScreenInjecting then return end TouchControls:touchreleased(id, x, y) diff --git a/src/core/SafeArea.lua b/src/core/SafeArea.lua index 86c404fbc..bf57c3c6d 100644 --- a/src/core/SafeArea.lua +++ b/src/core/SafeArea.lua @@ -8,23 +8,33 @@ -- canvas may still letterbox into the full framebuffer for immersion. local SafeArea = {} +local Viewport = require("src.render.GameViewport") -function SafeArea.rect() +function SafeArea.windowRect() local ww, wh = 0, 0 if love and love.graphics and love.graphics.getDimensions then ww, wh = love.graphics.getDimensions() end - if ww <= 0 then ww = 1 end - if wh <= 0 then wh = 1 end + if ww <= 0 then + ww = 1 + end + if wh <= 0 then + wh = 1 + end if not (love and love.window and love.window.getSafeArea) then return 0, 0, ww, wh end local x, y, w, h = love.window.getSafeArea() - if type(x) ~= "number" or type(y) ~= "number" - or type(w) ~= "number" or type(h) ~= "number" - or w <= 0 or h <= 0 then + if + type(x) ~= "number" + or type(y) ~= "number" + or type(w) ~= "number" + or type(h) ~= "number" + or w <= 0 + or h <= 0 + then return 0, 0, ww, wh end @@ -37,4 +47,8 @@ function SafeArea.rect() return x, y, w, h end +function SafeArea.rect() + return Viewport.localSafeRect(SafeArea.windowRect()) +end + return SafeArea diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index f9917f278..8727bd055 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -321,7 +321,7 @@ end -- demand. Mirrors it into self.orientation / self.positions / self.scale, -- which layout(), the editor chrome and the tests read. function TouchControls:currentBucket() - local _, _, sw, sh = SafeArea.rect() + local _, _, sw, sh = (SafeArea.windowRect or SafeArea.rect)() local o = orientationFor(sw, sh) self.layouts = self.layouts or { portrait = {}, landscape = {} } local b = self.layouts[o] @@ -347,7 +347,7 @@ end -- while sizes stay derived from the short edge, times the orientation's -- size setting (#633). function TouchControls:layout() - local ox, oy, sw, sh = SafeArea.rect() + local ox, oy, sw, sh = (SafeArea.windowRect or SafeArea.rect)() if self.layoutW == sw and self.layoutH == sh and self.layoutOx == ox and self.layoutOy == oy and self.layoutVersion == GameVersion.get() and self.L then @@ -383,7 +383,7 @@ end -- Move one control to a screen-space point and persist its normalized -- position within the safe rect. Used by the layout editor while dragging. function TouchControls:setControlCenter(name, cx, cy) - local ox, oy, sw, sh = SafeArea.rect() + local ox, oy, sw, sh = (SafeArea.windowRect or SafeArea.rect)() local L = self:layout() local zone = L[name] if not zone then return end diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index ffbd31431..0fe2cd6ad 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -18,7 +18,7 @@ add("hooks", "12345", [[ battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate encounter.fishing encounter.roll encounter.species evolution.check exp.gain - fieldmove.eligibility input.step link.fingerprint map.palette movement.collision + fieldmove.eligibility input.pointer 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.output render.output_enabled render.zones save.new_game save.write script.command trainer.party transition.style ui.list_menu ui.options.rows @@ -29,7 +29,7 @@ add("hooks", "12345", [[ -- 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", "123", "render.viewport render.window 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") diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index c051b099e..13ab6e048 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1394,6 +1394,8 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + local viewport = package.loaded["src.render.GameViewport"] + if viewport then viewport.reset() end Api3WildBattle.cancelAll() Api3NPC.cancelAll() Api3Warp.cancelAll() @@ -1741,6 +1743,8 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + local viewport = package.loaded["src.render.GameViewport"] + if viewport then viewport.reset() end Api3WildBattle.cancelAll() Api3NPC.cancelAll() Api3Warp.cancelAll() diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index ca4ae0034..f28caeadd 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -19,9 +19,9 @@ C.hooks = names([[ battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility - input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed + input.pointer 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.output render.output_enabled render.zones save.new_game save.write script.command trainer.party transition.style + render.letterbox render.output render.output_enabled render.viewport render.window 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 ]]) diff --git a/src/render/GameViewport.lua b/src/render/GameViewport.lua new file mode 100644 index 000000000..038cdd247 --- /dev/null +++ b/src/render/GameViewport.lua @@ -0,0 +1,241 @@ +-- Optional game rectangle inside the OS window. No canvas on the default path. +local Runtime = require("src.mods.Runtime") +local Scope = require("src.render.GraphicsScope") +local Logger = require("src.core.Logger") +local V = {} +local function finite(v) + return type(v) == "number" and v == v and math.abs(v) < math.huge +end +local function release() + if V.canvas then + if love and love.graphics and love.graphics.getCanvas and love.graphics.getCanvas() == V.canvas then + love.graphics.setCanvas() + end + if V.canvas.release then + pcall(V.canvas.release, V.canvas) + end + end + V.canvas = nil +end +function V.reset() + if V.inFrame then + V.retiring = true + return + end + release() + V.rect = nil + V.full = nil + V.generation = nil + V.frameActive = false + V.retiring = nil + V.failedSize = nil +end +local function rect(value, w, h) + if type(value) ~= "table" or getmetatable(value) ~= nil then + value = {} + end + local function field(k, default) + local v = rawget(value, k) + return finite(v) and math.floor(v) or default + end + local x, y = field("x", 0), field("y", 0) + x = math.max(0, math.min(x, w - 1)) + y = math.max(0, math.min(y, h - 1)) + return { + x = x, + y = y, + width = math.max(1, math.min(field("width", w), w - x)), + height = math.max(1, math.min(field("height", h), h - y)), + }, + rawget(value, "capture") == true +end +function V.begin(generation, allowed) + -- A previous frame can have been interrupted by a state error or launcher exit. + V.inFrame = false + if V.retiring then + V.reset() + end + local G = love.graphics + local w, h = G.getDimensions() + w = finite(w) and math.max(1, w) or 1 + h = finite(h) and math.max(1, h) or 1 + local pw, ph = w, h + if G.getPixelDimensions then + pw, ph = G.getPixelDimensions() + end + pw = finite(pw) and math.max(1, pw) or w + ph = finite(ph) and math.max(1, ph) or h + local context = + { width = w, height = h, pixelWidth = pw, pixelHeight = ph, dpiX = pw / w, dpiY = ph / h, generation = generation } + local requested + if allowed ~= false and Runtime.wantsHook("render.viewport") then + local ok, value = Scope.call(function() + return Runtime.call("render.viewport", function() + return { x = 0, y = 0, width = w, height = h } + end, context) + end) + if ok then + requested = value + else + Logger.warn("render viewport failed: %s", tostring(value)) + end + end + -- Retain real metrics independently of mutations to the hook argument. + V.full = + { width = w, height = h, pixelWidth = pw, pixelHeight = ph, dpiX = pw / w, dpiY = ph / h, generation = generation } + local r, capture = rect(requested, w, h) + local active = capture or r.x ~= 0 or r.y ~= 0 or r.width ~= w or r.height ~= h + V.rect, V.generation, V.frameActive = r, generation, active + if active then + local key = table.concat({ r.width, r.height, pw / w, ph / h }, ":") + if V.failedSize ~= key then + V.failedSize = nil + end + if V.failedSize == key then + active = false + elseif + not V.canvas + or V.canvas:getWidth() ~= r.width + or V.canvas:getHeight() ~= r.height + or V.canvasDpiX ~= pw / w + or V.canvasDpiY ~= ph / h + then + release() + local ok, canvas = pcall(G.newCanvas, r.width, r.height) + if ok and canvas then + V.canvas = canvas + canvas:setFilter("nearest", "nearest") + V.canvasDpiX, V.canvasDpiY = pw / w, ph / h + else + V.failedSize = key + active = false + Logger.warn("render viewport unavailable: %s", tostring(canvas)) + end + end + else + release() + V.failedSize = nil + end + if not active then + V.frameActive = false + V.rect = { x = 0, y = 0, width = w, height = h } + end + V.inFrame = true + return V.rect +end +function V.active() + return V.frameActive == true and V.canvas ~= nil +end +function V.dimensions() + if V.active() then + return V.rect.width, V.rect.height + end + return love.graphics.getDimensions() +end +function V.pixelDimensions() + if V.active() then + if V.canvas.getPixelDimensions then + return V.canvas:getPixelDimensions() + end + return math.max(1, math.floor(V.rect.width * V.full.dpiX)), + math.max(1, math.floor(V.rect.height * V.full.dpiY)) + end + if love.graphics.getPixelDimensions then + return love.graphics.getPixelDimensions() + end + return love.graphics.getDimensions() +end +function V.fullDimensions() + if V.full then + return V.full.width, V.full.height + end + return love.graphics.getDimensions() +end +function V.target() + return V.active() and V.canvas or nil +end +function V.setTarget() + love.graphics.setCanvas(V.target()) +end +function V.toLocal(x, y) + local r = V.rect + if not r then + return x, y, true + end + local lx, ly = x - r.x, y - r.y + return lx, ly, lx >= 0 and ly >= 0 and lx < r.width and ly < r.height +end +function V.localSafeRect(x, y, w, h) + if not V.active() then + return x, y, w, h + end + local r = V.rect + local x1, y1 = math.max(x, r.x), math.max(y, r.y) + local x2, y2 = math.min(x + w, r.x + r.width), math.min(y + h, r.y + r.height) + if x2 <= x1 or y2 <= y1 then + return 0, 0, r.width, r.height + end + return x1 - r.x, y1 - r.y, x2 - x1, y2 - y1 +end +local function prepare() + local G = love.graphics + G.setCanvas() + G.origin() + G.setScissor() + G.setShader() + G.setBlendMode("alpha") + G.setColor(1, 1, 1, 1) +end +function V.finish(game) + if V.active() then + local G = love.graphics + local r, full = V.rect, V.full + local context = { + canvas = V.canvas, + x = r.x, + y = r.y, + width = r.width, + height = r.height, + windowWidth = full.width, + windowHeight = full.height, + dpiX = full.dpiX, + dpiY = full.dpiY, + generation = V.generation, + } + G.setCanvas() + local function vanilla(_, ctx) + -- A failing outer link must not taint the default draw reached by next. + local canvas = type(ctx) == "table" and rawget(ctx, "canvas") + local x = type(ctx) == "table" and rawget(ctx, "x") + local y = type(ctx) == "table" and rawget(ctx, "y") + if not canvas or not finite(x) or not finite(y) then + error("invalid render.window context", 0) + end + local ok, why = Scope.call(function() + prepare() + G.draw(canvas, x, y) + end) + if not ok then + error(why, 0) + end + end + local ok, why = Scope.call(function() + prepare() + G.clear(0, 0, 0, 1) + return Runtime.call("render.window", vanilla, game, context) + end) + if not ok then + Logger.warn("render window failed: %s", tostring(why)) + Scope.call(function() + prepare() + G.clear(0, 0, 0, 1) + G.draw(V.canvas, r.x, r.y) + end) + end + end + V.inFrame = false + if V.retiring then + V.reset() + end +end +return V diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 59eeda645..3e7d8be13 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -13,6 +13,7 @@ local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") local Output = require("src.render.Output") +local GameViewport = require("src.render.GameViewport") -- RESOLVED ONCE, NOT EVERY FRAME. These three were `require`d from inside -- beginFrame and endFrame -- three string hashes into package.loaded on every @@ -86,10 +87,10 @@ Renderer.UPRIGHT_MARGIN = 160 -- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels -- on BOTH axes (square). local function displayMetrics() - local ww, wh = love.graphics.getDimensions() + local ww, wh = GameViewport.dimensions() local pw, ph = ww, wh if love.graphics.getPixelDimensions then - pw, ph = love.graphics.getPixelDimensions() + pw, ph = GameViewport.pixelDimensions() end local dpiX, dpiY = 1, 1 if ww > 0 and pw > 0 then dpiX = pw / ww end @@ -110,6 +111,7 @@ local function displayMetrics() end function Renderer:init() + GameViewport.reset() -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua -- (#208). Every canvas below is sized in framebuffer pixels for the same -- reason -- worldViewSize() already works in drawable pixels. @@ -828,7 +830,7 @@ end -- When GBC FX is active the composite is drawn into presentCanvas and -- presented through the GBC FX shader as a final pass. function Renderer:endFrame(zones, worldZones) - love.graphics.setCanvas() + GameViewport.setTarget() local ww, wh, pw, ph, dpiX, dpiY = displayMetrics() -- Sp = integer framebuffer pixels per GB pixel; -- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY). @@ -945,6 +947,7 @@ function Renderer:endFrame(zones, worldZones) -- what remains here is the dpiX vs dpiY truncation gap (well under 1%, -- one seam across the window) that a single scalar dpiscale cannot -- express. + if self.presentCanvas and self.presentCanvas.release then self.presentCanvas:release() end self.presentCanvas = love.graphics.newCanvas(ww, wh) -- NEAREST, like every other surface in this pipeline. -- @@ -1395,7 +1398,7 @@ function Renderer:endFrame(zones, worldZones) end if present then - love.graphics.setCanvas() + GameViewport.setTarget() -- Post-process pipelines run over the finished composite -- world, UI -- and all -- and before GBC FX, so a blur or colour grade is what the -- LCD grid is then drawn over rather than something that smears the diff --git a/tests/api3_contract/mods/viewport/main.lua b/tests/api3_contract/mods/viewport/main.lua new file mode 100644 index 000000000..08f76276f --- /dev/null +++ b/tests/api3_contract/mods/viewport/main.lua @@ -0,0 +1,68 @@ +local mod = ... +assert(mod:supports("hooks", "render.viewport")) +assert(mod:supports("hooks", "render.window")) +assert(mod:supports("hooks", "input.pointer")) +local request, canvasRef, windowAction = nil, nil, nil +local trace = { viewport = {}, window = {}, pointer = {} } +mod.hooks:wrap("render.viewport", function(next, ctx) + trace.viewport[#trace.viewport + 1] = { + width = ctx.width, + height = ctx.height, + pixelWidth = ctx.pixelWidth, + pixelHeight = ctx.pixelHeight, + dpiX = ctx.dpiX, + dpiY = ctx.dpiY, + generation = ctx.generation, + } + if request then + return request + end + return next(ctx) +end) +mod.hooks:wrap("render.window", function(next, game, ctx) + assert(ctx.canvas == canvasRef) + trace.window[#trace.window + 1] = { + x = ctx.x, + y = ctx.y, + width = ctx.width, + height = ctx.height, + windowWidth = ctx.windowWidth, + windowHeight = ctx.windowHeight, + dpiX = ctx.dpiX, + dpiY = ctx.dpiY, + generation = ctx.generation, + } + if windowAction then + return windowAction(next, game, ctx) + end + return next(game, ctx) +end) +mod.hooks:wrap("input.pointer", function(next, game, ctx) + trace.pointer[#trace.pointer + 1] = { + phase = ctx.phase, + source = ctx.source, + id = ctx.id, + x = ctx.x, + y = ctx.y, + gameX = ctx.gameX, + gameY = ctx.gameY, + insideGame = ctx.insideGame, + dx = ctx.dx, + dy = ctx.dy, + pressure = ctx.pressure, + button = ctx.button, + } + return next(game, ctx) +end) +mod.exports.request = function(value) + request = value +end +mod.exports.canvas = function(value) + canvasRef = value +end +mod.exports.window = function(fn) + windowAction = fn +end +mod.exports.trace = function() + return trace +end diff --git a/tests/api3_contract/mods/viewport/manifest.json b/tests/api3_contract/mods/viewport/manifest.json new file mode 100644 index 000000000..70047d193 --- /dev/null +++ b/tests/api3_contract/mods/viewport/manifest.json @@ -0,0 +1 @@ +{"id":"viewport","name":"Viewport/window contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/viewport.lua b/tests/api3_contract/viewport.lua new file mode 100644 index 000000000..2415fd319 --- /dev/null +++ b/tests/api3_contract/viewport.lua @@ -0,0 +1,48 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/viewport%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "viewport") + love = love or require("tests.love_stub") + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local V = require("src.render.GameViewport") + local Game = require(backend == "gen1" and f.gen > 1 and ("src.core.Game" .. f.gen) or "src.core.Game") + local G = love.graphics + local dims, pixels = G.getDimensions, G.getPixelDimensions + G.getDimensions = function() + return 640, 480 + end + G.getPixelDimensions = function() + return 1280, 960 + end + f.exports.request({ x = 100, y = 20, width = 320, height = 288 }) + local r = V.begin(f.gen) + assert(V.active() and r.x == 100 and r.y == 20 and r.width == 320 and r.height == 288) + assert(select(1, V.dimensions()) == 320) + assert(select(1, V.pixelDimensions()) == 640) + local x, y, inside = V.toLocal(132, 52) + assert(x == 32 and y == 32 and inside) + x, y, inside = V.toLocal(99, 19) + assert(x == -1 and y == -1 and not inside) + f.exports.canvas(V.target()) + Game.pointerEvent(f.game, "pressed", "mouse", "mouse", 132, 52, 0, 0, nil, 1) + Game.pointerEvent(f.game, "moved", "mouse", "mouse", 99, 19, -33, -33, nil, 1) + V.finish(f.game) + f.exports.request({ capture = true }) + V.begin(f.gen) + assert(V.active() and select(1, V.dimensions()) == 640) + f.exports.canvas(V.target()) + V.finish(f.game) + f.exports.request(nil) + V.begin(f.gen) + assert(not V.active()) + V.finish(f.game) + local report = f.exports.trace() + G.getDimensions, G.getPixelDimensions = dims, pixels + V.reset() + 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 0d24b2bbd..d10e79740 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -31,11 +31,14 @@ assert( or mode == "effects" or mode == "datasets" or mode == "render_output" + or mode == "viewport" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "render_output" then +if mode == "viewport" then + dofile(codeRoot .. "/viewport.lua").run(root, backend, version) +elseif mode == "render_output" then dofile(codeRoot .. "/render_output.lua").run(root, backend, version) elseif mode == "datasets" then dofile(codeRoot .. "/datasets.lua").run(root, backend, version) diff --git a/tests/engine/game_viewport.lua b/tests/engine/game_viewport.lua new file mode 100644 index 000000000..af029ece4 --- /dev/null +++ b/tests/engine/game_viewport.lua @@ -0,0 +1,258 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.viewport") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local V = require("src.render.GameViewport") +local Game = require("src.core.Game") +local Touch = require("src.core.TouchControls") +local Safe = require("src.core.SafeArea") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +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 viewport/window contract: " .. tostring(why)) +end +local G = love.graphics +local dimensions, pixels = G.getDimensions, G.getPixelDimensions +G.getDimensions = function() + return 640, 480 +end +G.getPixelDimensions = function() + return 1280, 960 +end +local function fixture() + V.finish({}) + V.reset() + local f = Contract.fixture("tests/api3_contract", "gen2", "red") + return f +end +local function begin(f, value) + f.exports.request(value) + local r = V.begin(1) + f.exports.canvas(V.target()) + return r +end +for _, value in ipairs({ + { x = -20, y = -40, width = 10000, height = 10000 }, + { x = 1 / 0, y = 0 / 0, width = -3, height = 0 }, + { x = 639.9, y = 479.9, width = 320, height = 288 }, + setmetatable({}, { + __index = function() + error("metatable must not execute") + end, + }), +}) do + local f = fixture() + local r = begin(f, value) + T.check(r.x >= 0 and r.y >= 0 and r.width >= 1 and r.height >= 1, "clamped viewport bounds") + T.check(r.x + r.width <= 640 and r.y + r.height <= 480, "viewport stays inside window") + V.finish(f.game) +end +do + local f = fixture() + local creates, releases = 0, 0 + local oldNew = G.newCanvas + G.newCanvas = function(...) + creates = creates + 1 + local c = oldNew(...) + c.release = function() + releases = releases + 1 + end + return c + end + begin(f, { x = 10, y = 20, width = 320, height = 288 }) + V.finish(f.game) + begin(f, { x = 11, y = 21, width = 320, height = 288 }) + V.finish(f.game) + T.eq(creates, 1, "moving rectangle reuses same-size canvas") + begin(f, { width = 321, height = 288 }) + V.finish(f.game) + T.eq(creates, 2, "resize allocates matching canvas") + T.eq(releases, 1, "resize releases old target") + begin(f, nil) + V.finish(f.game) + T.eq(releases, 2, "disabling releases viewport target") + G.newCanvas = oldNew +end +do + local f = fixture() + local oldNew = G.newCanvas + local attempts = 0 + G.newCanvas = function() + attempts = attempts + 1 + error("injected allocation failure") + end + local r = begin(f, { x = 10, y = 20, width = 100, height = 80 }) + T.eq(V.active(), false, "allocation failure keeps ordinary rendering") + T.eq(r.width, 640, "failed viewport falls back to full width") + V.finish(f.game) + begin(f, { x = 10, y = 20, width = 100, height = 80 }) + V.finish(f.game) + T.eq(attempts, 1, "failed size does not retry every frame") + G.newCanvas = oldNew + begin(f, { width = 101, height = 80 }) + V.finish(f.game) + T.eq(V.active(), true, "changed request retries successfully") + begin(f, { width = 100, height = 80 }) + V.finish(f.game) + T.eq(V.active(), true, "former failed size can recover after request change") +end +do + local f = fixture() + local n = #f.exports.trace().viewport + V.begin(4, false) + V.finish(f.game) + T.eq(V.active(), false, "unsupported/native combined layout stays direct") + T.eq(#f.exports.trace().viewport, n, "unsupported layout does not dispatch viewport request") + begin(f, { x = 100, y = 20, width = 320, height = 288 }) + local x, y, w, h = Safe.rect() + T.eq(x, 0, "safe area mapped to local x") + T.eq(y, 0, "safe area mapped to local y") + T.eq(w, 320, "safe area clipped to game width") + T.eq(h, 288, "safe area clipped to game height") + local ox, oy, ow, oh = Safe.windowRect() + T.eq(ow, 640, "OS chrome keeps full window width") + T.eq(oh, 480, "OS chrome keeps full window height") + V.finish(f.game) +end +for _, phase in ipairs({ "viewport", "window", "window_context" }) do + local f = fixture() + local shader, foreign = {}, {} + G.setShader(shader) + G.setColor(0.2, 0.3, 0.4, 0.5) + local mod = f.loader.apis.viewport + if phase == "viewport" then + mod.hooks:wrap("render.viewport", function(next, ctx) + G.setShader({}) + G.setCanvas(foreign) + G.push("all") + ctx.width = 0 + error("injected viewport hook error") + end, 100) + elseif phase == "window" then + f.exports.window(function() + G.setShader({}) + G.setCanvas(foreign) + G.push("all") + error("injected window hook error") + end) + else + f.exports.window(function(next, g) + return next(g, { canvas = false, x = 0, y = 0 }) + end) + end + begin(f, { x = 100, y = 20, width = 320, height = 288 }) + V.finish(f.game) + T.eq(G.getCanvas(), nil, "window target restored after " .. phase .. " failure") + T.eq(G.getShader(), shader, "shader restored after " .. phase .. " failure") + T.eq(select(1, G.getColor()), 0.2, "color restored after " .. phase .. " failure") +end +do + local f = fixture() + local resetInside = false + begin(f, { capture = true }) + local canvas = V.target() + canvas.release = function() + resetInside = true + end + f.exports.window(function(next, game, ctx) + V.reset() + T.eq(resetInside, false, "reset inside render callback defers canvas release") + return next(game, ctx) + end) + V.finish(f.game) + T.eq(resetInside, true, "retired target released after composition") + T.eq(V.active(), false, "deferred reset clears viewport state") +end +-- Actual native touch dispatch preserves pad and screen capture ownership. +do + local f = fixture() + begin(f, { x = 100, y = 20, width = 320, height = 288 }) + V.finish(f.game) + local old = { + Touch.visible, + Touch.hitTest, + Touch.touchpressed, + Touch.touchmoved, + Touch.touchreleased, + Touch.draw, + Touch.touches, + } + local pad, screen = {}, {} + Touch.touches = {} + Touch.visible = function() + return true + end + Touch.hitTest = function(_, x, y) + return x == 10 and y == 10 + end + Touch.touchpressed = function(_, id, x, y) + pad[#pad + 1] = { "pressed", id, x, y } + if id == "pad" then + Touch.touches[id] = true + end + end + Touch.touchmoved = function(_, id, x, y) + pad[#pad + 1] = { "moved", id, x, y } + end + Touch.touchreleased = function(_, id, x, y) + pad[#pad + 1] = { "released", id, x, y } + Touch.touches[id] = nil + end + local top = {} + for _, name in ipairs({ "touchpressed", "touchmoved", "touchreleased" }) do + top[name] = function(_, id, x, y) + screen[#screen + 1] = { name, id, x, y } + return true + end + end + local game = setmetatable( + { stack = { + top = function() + return top + end, + }, save = { options = {} } }, + { __index = Game } + ) + local mod = f.loader.apis.viewport + mod.hooks:wrap("render.window", function(next, g, ctx) + return next(g, ctx) + end, -100) + mod.hooks:wrap("input.pointer", function(next, g, ctx) + if ctx.id == "mod" or ctx.id == "native" and ctx.phase ~= "pressed" then + return true + end + return next(g, ctx) + end, 100) + game:touchpressed("pad", 10, 10) + game:touchmoved("pad", 20, 20) + game:touchreleased("pad", 20, 20) + T.eq(#f.exports.trace().pointer, 0, "virtual pad gesture never reaches mod pointer hook") + T.eq(#pad, 3, "virtual pad keeps entire gesture") + game:touchpressed("native", 132, 52, nil, nil, 0.5) + game:touchmoved("native", 149, 63) + game:touchreleased("native", 160, 64) + T.eq(#screen, 3, "native screen retains gesture after mod later claims movement") + T.eq(screen[1][3], 32, "native screen receives game-local x") + T.eq(screen[1][4], 32, "native screen receives game-local y") + T.eq(game.pointerOwners.native, nil, "native capture released") + game:touchpressed("mod", 132, 52) + game:touchmoved("mod", 149, 63) + game:touchreleased("mod", 160, 64) + T.eq(#screen, 3, "claimed mod gesture does not leak to native screen") + T.eq(game.modPointers.mod, nil, "mod pointer released") + game:touchpressed("outside", 99, 19) + T.eq(#screen, 3, "outside viewport does not activate native screen") + local last = pad[#pad] + T.eq(last[3], 99, "pad fallback keeps physical x") + T.eq(last[4], 19, "pad fallback keeps physical y") + Touch.visible, Touch.hitTest, Touch.touchpressed, Touch.touchmoved, Touch.touchreleased, Touch.draw, Touch.touches = + unpack(old) +end +V.finish({}) +V.reset() +G.getDimensions, G.getPixelDimensions = dimensions, pixels +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 4f23570f5..061f96aa6 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", "datasets", "render_output"): + 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", "render_output", "viewport"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures diff --git a/tools/game-viewport-check/conf.lua b/tools/game-viewport-check/conf.lua new file mode 100644 index 000000000..a09bfc987 --- /dev/null +++ b/tools/game-viewport-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity = "gen2-game-viewport-check" + t.window.title = "Gen2Recomped viewport GPU check" + t.window.width, t.window.height = 320, 288 + t.window.highdpi = true + t.modules.audio = false +end diff --git a/tools/game-viewport-check/main.lua b/tools/game-viewport-check/main.lua new file mode 100644 index 000000000..afc8cb4e9 --- /dev/null +++ b/tools/game-viewport-check/main.lua @@ -0,0 +1,210 @@ +-- Real Game:draw, Renderer, public API-3 mod and screenshot readback. +function love.errorhandler(message) + io.stderr:write(tostring(message) .. "\n" .. debug.traceback() .. "\n") + return function() + love.event.quit(1) + end +end +local root = love.filesystem.getSource():match("^(.*)/tools/game%-viewport%-check$") + or love.filesystem.getWorkingDirectory() +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +love.filesystem.mount(root, "", true) +local Contract = require("tests.api3_contract.viewport") +local Renderer = require("src.render.Renderer") +local V = require("src.render.GameViewport") +local Game = require("src.core.Game") +local Touch = require("src.core.TouchControls") +local Palette = require("src.render.PaletteFX") +local GBC = require("src.render.GBCFX") +local versions = { "red", "gold", "firered" } +local index, phase, checks, waiting = 1, 1, 0, false +local fixture, game, baseline, foreign, shader +local function check(ok, message) + checks = checks + 1 + assert(ok, message .. " (" .. versions[index] .. "/" .. phase .. ")") +end +local function near(a, b) + return math.abs(a - b) < 1.1 / 255 +end +local function scene(self) + Renderer:setUISize(160, 144) + Renderer:beginFrame() + local G = love.graphics + G.clear(1, 1, 1, 1) + G.setColor(0.2, 0.2, 0.2, 1) + G.rectangle("fill", 0, 0, 80, 72) + G.setColor(0.7, 0.7, 0.7, 1) + G.rectangle("fill", 80, 72, 80, 72) + G.setColor(1, 1, 1, 1) + Renderer:endFrame() + fixture.exports.canvas(V.target()) +end +local function start() + V.reset() + Renderer:init() + Palette.setMode("dmg") + GBC.setLevel(0) + fixture = Contract.fixture(root .. "/tests/api3_contract", "gen2", versions[index]) + game = fixture.game + setmetatable(game, { __index = Game }) + game._draw = scene + game.save.options = {} +end +function love.load() + local ok, why = xpcall(function() + shader = + love.graphics.newShader([[vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){return vec4(1.0,0.0,0.0,1.0);}]]) + foreign = love.graphics.newCanvas(4, 4) + Touch.draw = function() + if phase > 1 then + local G = love.graphics + G.setColor(0, 1, 0, 1) + G.rectangle("fill", 0, 0, 12, 12) + G.setColor(1, 1, 1, 1) + end + end + start() + end, debug.traceback) + if not ok then + error(why) + end +end +local function expected(pixels, rectangle, blue) + local pw, ph = pixels:getDimensions() + local dx, dy = pw / 320, ph / 288 + for y = 0, ph - 1 do + for x = 0, pw - 1 do + local target + if x < 12 * dx and y < 12 * dy then + target = { 0, 1, 0, 1 } + elseif + rectangle + and ( + x < rectangle.x * dx + or y < rectangle.y * dy + or x >= (rectangle.x + rectangle.width) * dx + or y >= (rectangle.y + rectangle.height) * dy + ) + then + target = blue and { 0, 0, 1, 1 } or { 0, 0, 0, 1 } + else + local bx, by = x, y + if rectangle then + bx = math.floor((x - rectangle.x * dx) * baseline:getWidth() / (rectangle.width * dx)) + by = math.floor((y - rectangle.y * dy) * baseline:getHeight() / (rectangle.height * dy)) + end + target = { baseline:getPixel(bx, by) } + end + local got = { pixels:getPixel(x, y) } + for channel = 1, 4 do + check(near(got[channel], target[channel]), "frame pixel " .. x .. "," .. y .. " channel " .. channel) + end + end + end +end +function love.draw() + if waiting then + return + end + waiting = true + local ok, why = xpcall(function() + fixture.exports.window(nil) + local rectangle + if phase >= 2 and phase <= 5 then + rectangle = { x = 80, y = 72, width = 160, height = 144 } + elseif phase == 7 then + rectangle = { x = 0, y = 0, width = 320, height = 288, capture = true } + elseif phase == 8 then + rectangle = { x = 0, y = 0, width = 160, height = 144 } + elseif phase == 9 then + rectangle = { x = 20, y = 20, width = 161, height = 144 } + end + fixture.exports.request(rectangle) + if phase == 3 then + fixture.exports.window(function(next, g, ctx) + love.graphics.setColor(0, 0, 1, 1) + love.graphics.rectangle("fill", 0, 0, ctx.windowWidth, ctx.windowHeight) + return next(g, ctx) + end) + end + if phase == 4 then + fixture.exports.window(function() + local G = love.graphics + G.push("all") + G.setCanvas(foreign) + G.setShader(shader) + G.translate(50, 70) + error("injected window compositor failure") + end) + end + if phase == 5 then + fixture.exports.window(function(next, g, ctx) + V.reset() + return next(g, ctx) + end) + end + local G = love.graphics + local oldNew = G.newCanvas + if phase == 9 then + G.newCanvas = function() + error("injected viewport allocation failure") + end + end + local depth = G.getStackDepth() + local drawn, err = xpcall(function() + Game.draw(game) + end, debug.traceback) + G.newCanvas = oldNew + if not drawn then + error(err) + end + check(G.getCanvas() == nil, "OS window is final target") + check(G.getShader() == nil, "window compositor cannot leak shader") + check(G.getStackDepth() == depth, "graphics stack balanced") + local x, y = G.transformPoint(0, 0) + check(x == 0 and y == 0, "window transform restored") + if phase == 1 then + check(not V.active() and V.target() == nil, "default path has no viewport target") + end + if phase == 5 or phase == 9 then + check(not V.active(), "retirement/allocation failure clears capture") + end + G.captureScreenshot(function(pixels) + local good, problem = xpcall(function() + if phase == 1 then + baseline = pixels + else + local expectedRectangle = rectangle + if phase == 9 then + expectedRectangle = nil + end + expected(pixels, expectedRectangle, phase == 3) + pixels:release() + end + phase = phase + 1 + if phase == 10 then + baseline:release() + index = index + 1 + phase = 1 + if index > #versions then + print("GAME VIEWPORT GPU: " .. checks .. " checks passed across Red/Gold/FireRed") + local a, b, c, d = G.getRendererInfo() + print(table.concat({ a, b, c, d }, " | ")) + love.event.quit(0) + return + end + start() + end + waiting = false + end, debug.traceback) + if not good then + io.stderr:write(problem .. "\n") + love.event.quit(1) + end + end) + end, debug.traceback) + if not ok then + io.stderr:write(why .. "\n") + love.event.quit(1) + end +end From fb22e4a1ad1e7278559fb42ae4d290e226a78a32 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 00:54:27 +0200 Subject: [PATCH 036/187] Publish Gen2 palette row metadata for native atlas renderers --- src/render/PaletteFX.lua | 4 ++++ tests/engine/gen2_palette_queries.lua | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 tests/engine/gen2_palette_queries.lua diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 88984d94b..b07099033 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -467,6 +467,10 @@ local gen2ClockTod = "DAY" -- what the clock alone says local gen2MapPalette = 0 -- PALETTE_* from the current map's header -- OverworldState:timeOfDay answers "MORNING"/"NITE"; a mod's world.tod hook may -- answer "NIGHT". Normalise to the four row names the importer writes. +-- Public ROM row order, shared with native Gen2 atlas consumers. +PaletteFX.DAYTIMES = { "MORN", "DAY", "NITE", "DARK" } +PaletteFX.DAYTIME_ID = { MORN = 1, DAY = 2, NITE = 3, DARK = 4 } + local GEN2_TOD_ALIAS = { MORN = "MORN", MORNING = "MORN", DAY = "DAY", DAYTIME = "DAY", NITE = "NITE", NIGHT = "NITE", EVE = "NITE", EVENING = "NITE", diff --git a/tests/engine/gen2_palette_queries.lua b/tests/engine/gen2_palette_queries.lua new file mode 100644 index 000000000..3bc458519 --- /dev/null +++ b/tests/engine/gen2_palette_queries.lua @@ -0,0 +1,27 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local P=require('src.render.PaletteFX') +local Data=require('src.core.Data') +local original={tilesets=Data.tilesets,palettes=Data.gen2Palettes,mode=P.mode,dark=P.darkWorld()} +P.setMode('gbc');P.setDarkWorld(false) +local rows={};for i,tod in ipairs({'MORN','DAY','NITE','DARK'})do + rows[tod]={};for slot=1,8 do rows[tod][slot]={{i,slot,1},{i,slot,2},{i,slot,3},{i,slot,4}}end +end +Data.tilesets={JOHTO={palColorsByTod=rows,palMap={0,1,7}}};Data:publishGen2Palettes() +for i,tod in ipairs({'MORN','DAY','NITE','DARK'})do + T.eq(P.DAYTIMES[i],tod,'ROM row order '..tod) + T.eq(P.DAYTIME_ID[tod],i,'public daytime lookup '..tod) + -- Native external atlas call shape: preserve the effective world daytime, + -- then query the published data without changing the live native state. + local effective=tod + local daytime=P.daytimeFor({tileset='JOHTO'},12,false) + if P.DAYTIME_ID[effective]then daytime=effective end + local set,row=P.bgSet(Data.gen2Palettes,{tileset='JOHTO'},daytime) + T.eq(set,rows[tod],'native external atlas receives actual row '..tod) + T.eq(row,tod,'resolved row name '..tod) +end +T.eq(P.DAYTIME_ID.UNKNOWN,nil,'unknown row not accepted') +T.eq(P.bgSet(Data.gen2Palettes,{tileset='MISSING'},'DAY'),nil,'missing tileset stays unavailable') +P.setMode('og');T.eq(P.bgSet(Data.gen2Palettes,{tileset='JOHTO'},'DAY'),nil,'DMG color choice honored') +Data.tilesets,Data.gen2Palettes=original.tilesets,original.palettes;P.setMode(original.mode);P.setDarkWorld(original.dark) +T.finish('Gen2 public palette query rows') From 3b170ce280c4bb1414ed1a6bfb6b45bf0102404b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:01:32 +0100 Subject: [PATCH 037/187] Expose raw RTC daytime alongside native Gen2 map palette queries --- src/render/PaletteFX.lua | 20 +++++++++----- tests/engine/gen2_palette_clock.lua | 43 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 tests/engine/gen2_palette_clock.lua diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index b07099033..0475e29a4 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -539,6 +539,18 @@ function PaletteFX.gen2Tod() return gen2Tod end -- bgSet fetches it (LoadMapPals). Neither reads or writes the live state, so -- calling them cannot disturb what the 2D atlas was baked with. +-- Public RTC-only query used by Gen2 scene renderers (including train rides). +-- It must not inherit the live map override, darkness, or atlas bake state. +function PaletteFX.clockDaytime(hour) + hour = tonumber(hour) + if not hour then hour = tonumber(os.date("%H")) end + hour = math.floor(hour or 12) % 24 + if hour < 4 then return "NITE" end + if hour < 10 then return "MORN" end + if hour < 18 then return "DAY" end + return "NITE" +end + -- Which of MORN / DAY / NITE / DARK a map takes. -- -- mapDef a record out of data.maps; its `mapPalette` is map header @@ -563,13 +575,7 @@ function PaletteFX.daytimeFor(mapDef, hour, flashUsed) if override then return override end -- AUTO: GetTimeOfDay's own table (5:$4032 .TimeOfDayTable). - hour = tonumber(hour) - if not hour then hour = tonumber(os.date("%H")) end - hour = math.floor(hour or 12) % 24 - if hour < 4 then return "NITE" end - if hour < 10 then return "MORN" end - if hour < 18 then return "DAY" end - return "NITE" + return PaletteFX.clockDaytime(hour) end -- The eight BG palettes a map wears on a given row. diff --git a/tests/engine/gen2_palette_clock.lua b/tests/engine/gen2_palette_clock.lua new file mode 100644 index 000000000..1beb3dc13 --- /dev/null +++ b/tests/engine/gen2_palette_clock.lua @@ -0,0 +1,43 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes') +local previous={dark=P.darkWorld(),tod=P.gen2Tod(),map=P.gen2MapPalette(),date=os.date} +T.eq(P,require('src.render.PaletteFX'),'public Gen2 palette spelling shares actual native module') +P.setDarkWorld(false);P.setGen2MapPalette(0) +local cases={{0,'NITE'},{3.99,'NITE'},{4,'MORN'},{9.99,'MORN'},{10,'DAY'},{17.99,'DAY'}, + {18,'NITE'},{23.99,'NITE'},{24,'NITE'},{28,'MORN'},{34,'DAY'},{-1,'NITE'}} +for _,case in ipairs(cases)do + T.eq(P.clockDaytime(case[1]),case[2],'RTC query boundary '..case[1]) + T.eq(P.daytimeFor({mapPalette=0},case[1],false),case[2],'native AUTO query shares boundary '..case[1]) +end +for _,v in ipairs({'gold','silver','crystal'})do + V.set(v) + os.date=function(format,...)if format=='%H'then return '19'end;return previous.date(format,...)end + P.setGen2Tod('MORN');P.setGen2MapPalette(1);P.setDarkWorld(true) + T.eq(P.clockDaytime(),'NITE',v..' scene RTC query ignores map DAY and darkness') + T.eq(P.daytimeFor({mapPalette=1},nil,true),'DAY',v..' map override still selects its own row') + T.eq(P.daytimeFor({mapPalette=1},nil,false),'DARK',v..' native darkness still wins before map override') + T.eq(P.gen2Tod(),'DAY',v..' public clock query never changes live bake state') + local Json=require('src.link.Json');local d=T.fixtures.fresh() + local files={['mods/clock/manifest.json']=Json.encode({id='clock',name='Clock contract',version='1.0.0',api=2,entry='main.lua',affects_link=false}), + ['mods/clock/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + -- Authored scene contract: select a sky from the raw RTC, independently + -- of the current room's forced palette. No renderer/mod art is included. + local sky={MORN='early',DAY='sunny',NITE='late'} + mod.exports.sky=sky[Palettes.clockDaytime()] + mod.exports.override=Palettes.daytimeFor({mapPalette=1},nil,true) + ]]} + local r=T.sdk.loadMods({'mods/clock'},{data=d,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,v..' actual Loader accepts unchanged public clock call') + local exports=r.loader.exports.clock + T.eq(exports.sky,'late',v..' loaded scene gets RTC sky instead of nil-method failure') + T.eq(exports.override,'DAY',v..' loaded scene can separately query map palette') + require('src.mods.Loader').teardown();r.release() +end +os.date=previous.date;P.setDarkWorld(previous.dark);P.setGen2MapPalette(previous.map);P.setGen2Tod(previous.tod);V.set(version) +T.finish('Gen2 public scene RTC palette query') From f50726a5cde7be61d26685785a352a056e164249 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:37:00 +0100 Subject: [PATCH 038/187] Share native per-map palettes, roof rules and entered world context --- docs/native-gen2-palette-context.md | 53 +++++++++ src/core/Data.lua | 4 + src/core/GameVersion.lua | 5 + src/mods/Loader.lua | 4 + src/render/Gen2PaletteContext.lua | 74 ++++++++++++ src/render/PaletteFX.lua | 36 +++++- src/render/TileRenderer.lua | 98 ++++------------ src/world/OverworldController.lua | 21 +++- tests/engine/gen2_palette_clock.lua | 2 +- tests/engine/gen2_palette_context.lua | 158 ++++++++++++++++++++++++++ 10 files changed, 374 insertions(+), 81 deletions(-) create mode 100644 docs/native-gen2-palette-context.md create mode 100644 src/render/Gen2PaletteContext.lua create mode 100644 tests/engine/gen2_palette_context.lua diff --git a/docs/native-gen2-palette-context.md b/docs/native-gen2-palette-context.md new file mode 100644 index 000000000..68450c9e8 --- /dev/null +++ b/docs/native-gen2-palette-context.md @@ -0,0 +1,53 @@ +# Native Gen2 map palette context + +Native GSC maps store the header's palette nibble in `mapPalette`. External +renderers also read the symbolic `def.palette`, `world.daytime` and +`world.flashUsed` contracts. These read views now expose native facts without +seeding another clock, Flash bit or palette catalogue. + +Map palette names are computed from the current byte. Explicit authored +values and existing record metatables are preserved. Unused native header +slots 5..7 follow AUTO; invalid/missing bytes do not invent a named palette. +The public world's effective daytime reads the actual palette bake state, +including darkness; Flash reads the actual `save.flashLit` field written by +the native script command. Explicit authored Lua fields retain precedence. +This does not implement the separate saved RTC-base protocol or every legacy +world mutation method. + +`PaletteFX.daytimeFor(def, hour, flashUsed)` resolves a known map from its own +header. A neighbouring room stays DAY while the current cave is dark, and an +unentered DARK cave stays dark until Flash is supplied. Metadata-free old +queries retain their prior live-state fallback. The native renderer uses +`gen2RowFor` to distinguish the entered map's actual presentation from a +neighbour's own header and the active clock/Flash presentation state. Native +map entry and Flash update that state; atlas cache keys include the selected +row. Animated overdraw receives the same selected palette as the static bake. +An omitted hour uses the entered world's native clock row, including its +`world.tod` selection; outside an entered context it keeps the host-clock +fallback. An explicit hour remains authoritative. Mod load/unload and edition +changes retire the entered context, so returning to the launcher cannot +revive a previous world's clock selection. + +`PaletteFX.bgSet` accepts the full native dataset or its published +`gen2Palettes` view. The latter carries a private association with its owner, +so imported special-map rules and roof pairs remain available. A superseded +view cannot combine old tileset rows with new metadata. Colourless caches and +DMG mode still decline colouring. + +Special-map rule ordering and roof selection are shared with `TileRenderer`. +The first matching special rule owns the decision even if its set is missing; +darkness skips specials. Roof replacement copies only the row list and two +middle roof colours, retaining endpoints and the source tileset. Live metadata +edits are observed through the normal native invalidation/rebuild lifecycle. +No ROM colours, dialogue, source art or private mod implementation is bundled. + +Synthetic tests exercise the actual Loader, public module calls, native Flash +writer and `TileRenderer` CPU atlas bake. Owned in-memory pixel/image adapters +replace graphics IO, and baked RGB samples are compared with the public query +for AUTO, forced rows, dark caves, special maps and roofs. These are not GPU +or complete unchanged-VASC gameplay acceptance tests. General legacy +`map.palette` row-hook semantics and saved RTC integration remain separate +parity work; the existing SGB palette-name hook is preserved. + +Original native selection routines retain Cedric's rights; additional +integration is covered by [the Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/src/core/Data.lua b/src/core/Data.lua index ca41e93a0..8863bbd8b 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -685,6 +685,9 @@ local function gen2PaletteRows(tileset) end function Data:publishGen2Palettes() + if require('src.core.GameVersion').isGen2()then + for _,def in pairs(self.maps or{})do require('src.render.Gen2PaletteContext').publish(def)end + end local tilesets = self.tilesets if type(tilesets) ~= "table" then return false end @@ -735,6 +738,7 @@ function Data:publishGen2Palettes() self.gen2Palettes = setmetatable({}, { __index = function(_, id) return gen2PaletteRows(tilesets[id]) end, }) + require('src.render.Gen2PaletteContext').bind(self.gen2Palettes,self) return true end diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 330f4f3e9..6c92886cf 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1082,7 +1082,12 @@ GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal", GameVersion.current = "red" function GameVersion.set(id) + local previous=GameVersion.current GameVersion.current = GameVersion.VERSIONS[id] and id or "red" + if previous~=GameVersion.current then + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context()end + end return GameVersion.current end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..cd3ab6876 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1310,6 +1310,8 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context()end -- 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 +1655,8 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context()end local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/render/Gen2PaletteContext.lua b/src/render/Gen2PaletteContext.lua new file mode 100644 index 000000000..d5631b5f4 --- /dev/null +++ b/src/render/Gen2PaletteContext.lua @@ -0,0 +1,74 @@ +-- Native special-map/roof selection adapted from TileRenderer.lua: +-- Copyright (c) 2026 Cedric. All rights reserved. See LICENSE.md. +-- Additional context/view integration (c) 2026 Roxas2712. +-- See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md; existing upstream rights remain unchanged. +local M={} +local names={[0]='PALETTE_AUTO','PALETTE_DAY','PALETTE_NITE','PALETTE_MORN','PALETTE_DARK', + 'PALETTE_AUTO','PALETTE_AUTO','PALETTE_AUTO'} +local ids={PALETTE_AUTO=0,PALETTE_DAY=1,PALETTE_NITE=2,PALETTE_MORN=3,PALETTE_DARK=4} +local views=setmetatable({},{__mode='k'}) +function M.id(def) + if type(def)~='table'then return nil end + local authored=rawget(def,'palette') + if ids[authored]~=nil then return ids[authored]end + local n=tonumber(def.mapPalette) + return n and n%1==0 and n>=0 and n<=7 and n or nil +end +function M.publish(def) + if type(def)~='table'or rawget(def,'palette')~=nil or getmetatable(def)~=nil then return false end + setmetatable(def,{__index=function(t,key) + if key=='palette'then return names[M.id(t)]end + end}) + return true +end +function M.bind(view,data)views[view]={data=data,tilesets=data.tilesets}end +function M.owner(data) + if type(data)~='table'then return nil end + local binding=views[data] + if binding then + local owner=binding.data + return owner.gen2Palettes==data and owner.tilesets==binding.tilesets and owner or nil,true + end + return type(data.tilesets)=='table'and data or nil,false +end +function M.special(data,def,tileset,row) + if row=='DARK'then return nil end + local special=data and data.field and data.field.gen2MapPalettes + if type(def)~='table'or type(special)~='table'or type(special.rules)~='table'then return nil end + for _,rule in ipairs(special.rules)do + local hit + if rule.kind=='map'then hit=rule.group==def.group and rule.number==def.number + elseif rule.kind=='landmark'then hit=rule.landmark==def.landmark + elseif rule.kind=='tileset'then hit=rule.tileset==tileset end + if hit and rule.landmarkMin then hit=type(def.landmark)=='number'and def.landmark>=rule.landmarkMin end + if hit and rule.landmarkEq then hit=def.landmark==rule.landmarkEq end + if hit then + local set=special.sets and special.sets[rule.set] + local rows=set and(set.ALL or set[row]or set.DAY) + if rows and #rows>0 then return '#pal:'..tostring(rule.set),rows end + return nil -- The first matching rule owns the decision, even if unavailable. + end + end +end +function M.roof(data,def,tileset,row) + local roofs=data and data.field and data.field.gen2Roofs + if type(roofs)~='table'or type(tileset)~='table'or + type(roofs.tilesets)~='table'or not roofs.tilesets[tileset.id]then return nil end + local group=def and def.group + local id=group~=nil and type(roofs.byGroup)=='table'and roofs.byGroup[group]or nil + if id==nil then return nil end + local image=type(roofs.images)=='table'and roofs.images[id]or nil + if not image then return nil end + local byTod=type(roofs.colors)=='table'and roofs.colors[group]or nil + local pair=byTod and(byTod[row]or byTod.DAY)or nil + return{image=image,pair=pair,slot=roofs.slot or 10,count=roofs.count or 9,palIndex=roofs.palIndex or 6, + variants=tileset.tileVariants,key='#roof:'..tostring(id)..'.'..tostring(group)} +end +function M.withRoof(rows,roof) + if not(roof and roof.pair)then return rows end + local out={};for i=1,#rows do out[i]=rows[i]end + local slot=roof.palIndex+1;local row=out[slot] + if row then out[slot]={row[1],roof.pair[1],roof.pair[2],row[4]}end + return out +end +return M diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 0475e29a4..c95d9d31e 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -489,6 +489,23 @@ local GEN2_TOD_ALIAS = { local GEN2_MAP_PALETTE_ROW = { [1] = "DAY", [2] = "NITE", [3] = "MORN", [4] = "NITE", } +local gen2FlashUsed=false +local gen2CurrentDef +function PaletteFX.clearGen2Context()gen2CurrentDef=nil;gen2FlashUsed=false end +function PaletteFX.bindGen2Context(def,flashUsed) + gen2CurrentDef=type(def)=='table'and def or nil;gen2FlashUsed=flashUsed==true +end +function PaletteFX.setGen2FlashUsed(on)gen2FlashUsed=on==true end +function PaletteFX.effectiveGen2Tod()return darkWorld and 'DARK'or gen2Tod end +-- A neighbour uses its own header, the native hooked clock and the same Flash +-- presentation state. It must not inherit the current map's forced DAY row. +function PaletteFX.gen2RowFor(def) + if def~=nil and def==gen2CurrentDef then return PaletteFX.effectiveGen2Tod()end + local id=require('src.render.Gen2PaletteContext').id(def) + if id==nil then return PaletteFX.effectiveGen2Tod() end + if id==4 then return gen2FlashUsed and 'NITE'or 'DARK'end + return GEN2_MAP_PALETTE_ROW[id]or gen2ClockTod +end local function gen2RecomputeTod() local key = GEN2_MAP_PALETTE_ROW[gen2MapPalette] or gen2ClockTod @@ -506,7 +523,8 @@ end -- Called on every map change with the map's PALETTE_* byte (0 = AUTO). -- Same contract as setGen2Tod: true means the atlases are stale. -function PaletteFX.setGen2MapPalette(palette) +function PaletteFX.setGen2MapPalette(palette, mapDef) + gen2CurrentDef=type(mapDef)=='table'and mapDef or nil local value = tonumber(palette) or 0 if value < 0 or value > 7 then value = 0 end -- PALETTE_5..7 are unused slots that ReplaceTimeOfDayPals maps to $E4, the @@ -562,6 +580,11 @@ end -- a caller that has no flash flag to offer gets the engine's own -- darkWorld state, which already folds in save.flashLit. function PaletteFX.daytimeFor(mapDef, hour, flashUsed) + local id=require('src.render.Gen2PaletteContext').id(mapDef) + if id~=nil then + if id==4 then return flashUsed==true and 'NITE'or 'DARK'end + return GEN2_MAP_PALETTE_ROW[id]or(hour==nil and gen2CurrentDef and gen2ClockTod)or PaletteFX.clockDaytime(hour) + end -- Darkness wins outright and the clock stops mattering, which is what -- .NeedsFlash does: an unlit cave is the DARKNESS row at noon. if flashUsed ~= true and darkWorld then return "DARK" end @@ -606,9 +629,18 @@ function PaletteFX.bgSet(data, mapDef, daytime) end if type(rows) ~= "table" then return nil end - local row = GEN2_TOD_ALIAS[tostring(daytime or ""):upper()] or gen2Tod + local row = GEN2_TOD_ALIAS[tostring(daytime or ""):upper()] or PaletteFX.gen2RowFor(mapDef) local set = rows[row] or rows.DAY if type(set) ~= "table" or set[1] == nil then return nil end + local context=require('src.render.Gen2PaletteContext') + local owner,bound=context.owner(data) + if bound and not owner then return nil end + if owner then + local _,special=context.special(owner,mapDef,tilesetId,row) + if special then set=special end + local tileset=owner.tilesets and owner.tilesets[tilesetId] + set=context.withRoof(set,context.roof(owner,mapDef,tileset,row)) + end return set, row end diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 5f7a007a5..a336f0035 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -568,19 +568,20 @@ end -- palColorsByTod is what the Gen2 importer now writes; palColors alone is the -- older single-row shape, kept working so a dataset extracted before this -- change still renders instead of falling back to grey. -local function gen2PalColors(tileset) +local function gen2PalColors(tileset, row) local byTod = tileset.palColorsByTod if type(byTod) ~= "table" then return tileset.palColors end - if PaletteFX.darkWorld() then return byTod.DARK or tileset.palColors end - return byTod[PaletteFX.gen2Tod()] or byTod.DAY or tileset.palColors + row=row or PaletteFX.effectiveGen2Tod() + if row=='DARK'then return byTod.DARK or tileset.palColors end + return byTod[row] or byTod.DAY or tileset.palColors end -- One atlas per (tileset image, COLORS mode, time of day). Leaving the mode -- out of this key is why switching COLORS did nothing out in the Gen2 -- overworld: the first bake won and every later mode kept being handed it. -local function gen2AtlasKey(imagePath, roof, special) +local function gen2AtlasKey(imagePath, roof, special, row) return imagePath .. "#gen2pal:" .. tostring(PaletteFX.mode) .. ":" - .. (PaletteFX.darkWorld() and "DARK" or tostring(PaletteFX.gen2Tod())) + .. tostring(row or PaletteFX.effectiveGen2Tod()) -- TWO MAPS ON ONE TILESET ARE TWO DIFFERENT PICTURES once the roof is in -- it. Violet and Azalea share TilesetJohto1 and wear different roofs, so -- leaving the roof out of the key would hand the second town the first @@ -601,36 +602,10 @@ end -- -- Skipped outright while the world is dark, which keeps the ROM's ordering: -- the darkness row is the first row in that table and beats every other. -local function gen2SpecialPalette(map, data) - if PaletteFX.darkWorld() then return nil end - local special = data and data.field and data.field.gen2MapPalettes - if type(special) ~= "table" or type(special.rules) ~= "table" then return nil end - local def = data.maps and map.id and data.maps[map.id] - if type(def) ~= "table" then return nil end - local tileset = map.tileset and map.tileset.id - for _, rule in ipairs(special.rules) do - local hit - if rule.kind == "map" then - hit = rule.group == def.group and rule.number == def.number - elseif rule.kind == "landmark" then - hit = rule.landmark == def.landmark - elseif rule.kind == "tileset" then - hit = rule.tileset == tileset - end - -- RegionCheck, as PokeCenterSpecialCase applies it - if hit and rule.landmarkMin then - hit = type(def.landmark) == "number" and def.landmark >= rule.landmarkMin - end - if hit and rule.landmarkEq then hit = def.landmark == rule.landmarkEq end - if hit then - local set = special.sets and special.sets[rule.set] - local rows = set and (set.ALL - or set[PaletteFX.gen2Tod()] or set.DAY) - if rows and #rows > 0 then return "#pal:" .. tostring(rule.set), rows end - return nil - end - end - return nil +local function gen2SpecialPalette(map, data, row) + local def=data.maps and map.id and data.maps[map.id] or map.def + return require('src.render.Gen2PaletteContext').special(data,def, + map.tileset and map.tileset.id,row or PaletteFX.effectiveGen2Tod()) end -- THE ROOF A MAP WEARS, or nil when it wears none. @@ -639,33 +614,10 @@ end -- tileset (see RomExtractorGen2:gen2Roofs for the cartridge routines). The -- art is a nine-tile strip on disk and the colour is a PAIR -- colours 1 and 2 -- of the roof palette row, which is all LoadMapPals ever writes. -local function gen2RoofFor(map, data) - local roofs = data and data.field and data.field.gen2Roofs - if type(roofs) ~= "table" then return nil end - local tileset = map and map.tileset - if not (tileset and type(roofs.tilesets) == "table" - and roofs.tilesets[tileset.id]) then - return nil - end - local def = data.maps and map.id and data.maps[map.id] - local group = (def and def.group) or map.group - local id = group ~= nil and type(roofs.byGroup) == "table" - and roofs.byGroup[group] or nil - if id == nil then return nil end - local image = type(roofs.images) == "table" and roofs.images[id] or nil - if not image then return nil end - local byTod = type(roofs.colors) == "table" and roofs.colors[group] or nil - local pair = byTod and (PaletteFX.darkWorld() and byTod.DARK - or byTod[PaletteFX.gen2Tod()] or byTod.DAY) or nil - return { - image = image, - pair = pair, - slot = roofs.slot or 10, - count = roofs.count or 9, - palIndex = roofs.palIndex or 6, - variants = tileset.tileVariants, - key = "#roof:" .. tostring(id) .. "." .. tostring(group), - } +local function gen2RoofFor(map, data, row) + local def=data.maps and map.id and data.maps[map.id] or map.def or map + return require('src.render.Gen2PaletteContext').roof(data,def,map.tileset, + row or PaletteFX.effectiveGen2Tod()) end local gen2AtlasCache = {} @@ -1368,14 +1320,17 @@ function TileRenderer.new(map, data) -- "show me the hardware", and the DMG/SGB-flavoured modes have to fall -- through to the raw sheet and their own shade treatment. Unconditional, it -- made COLORS a no-op for every Gen2 tile on screen. - local gen2Colors = PaletteFX.usesGen2BgPal() and gen2PalColors(map.tileset) + local gen2Def=data and data.maps and map.id and data.maps[map.id] or map.def + local gen2Enabled=PaletteFX.usesGen2BgPal() + local gen2Row=gen2Enabled and PaletteFX.gen2RowFor(gen2Def) + local gen2Colors = gen2Enabled and gen2PalColors(map.tileset,gen2Row) -- ...and this map's own seven, where the cartridge gives it seven of its -- own. Before the roof swap below, because the ROM does it in that order: -- LoadMapPals picks the palettes, special or environment, and only then -- writes the map group's roof pair into whichever set it ended up with. local specialKey if gen2Colors then - local key, rows = gen2SpecialPalette(map, data) + local key, rows = gen2SpecialPalette(map, data,gen2Row) if rows then gen2Colors, specialKey = rows, key end end if not self.gbcAtlas and map.tileset.palMap and #map.tileset.palMap > 0 @@ -1386,18 +1341,9 @@ function TileRenderer.new(map, data) -- list with just that pair replaced is the whole of it. A copy, because -- `gen2Colors` is the tileset def's own table and two groups sharing a -- tileset would otherwise overwrite each other's roof. - local roof = gen2RoofFor(map, data) - if roof and roof.pair then - local swapped = {} - for i = 1, #gen2Colors do swapped[i] = gen2Colors[i] end - local row = swapped[roof.palIndex + 1] - if row then - swapped[roof.palIndex + 1] = - { row[1], roof.pair[1], roof.pair[2], row[4] } - end - gen2Colors = swapped - end - local key = gen2AtlasKey(map.tileset.image, roof, specialKey) + local roof = gen2RoofFor(map, data,gen2Row) + gen2Colors=require('src.render.Gen2PaletteContext').withRoof(gen2Colors,roof) + local key = gen2AtlasKey(map.tileset.image, roof, specialKey,gen2Row) local gen2img = getGen2Atlas(key, map.tileset.image, map.tileset.tilesPerRow, map.tileset.palMap, gen2Colors, TileRenderer.borrowStart(map.tileset), roof) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 54ac1c659..48fa02855 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -67,6 +67,13 @@ end local OverworldState = { isOpaque = true, isOverworld = true } local Game -- set on enter (avoids circular require at load time) +-- Read the active native bake/Flash fields rather than copying another world. +setmetatable(OverworldState,{__index=function(world,key) + if(key=='daytime'or key=='flashUsed')and GameVersion.isGen2()and world.map then + if key=='daytime'then return PaletteFX.effectiveGen2Tod()end + return Game and Game.save and Game.save.flashLit==true or false + end +end}) -- AN EMPTY PLOT DRAWS NOTHING, asked once per entity per frame. Written -- with `pcall(function() ... require(...) ... end)` this was a closure, a @@ -167,6 +174,8 @@ local function isDarkMap(mapId) -- ReplaceTimeOfDayPals sends down .NeedsFlash. Reading Gen1's pokered -- list here matched nothing, so Dark Cave and the Whirl Islands were lit. local def = Game.data.maps[mapId] + local palette=require('src.render.Gen2PaletteContext').id(def) + if palette~=nil then return palette==4 end local dark = Game.data.field.gen2DarkMaps if (def and dark and dark[def.label]) == true then return true end -- Fallback when gen2DarkMaps was not extracted (stale cache): maps @@ -1164,7 +1173,8 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- and the Ruins of Alph chambers taking the NITE row after 6pm. if GameVersion.isGen2() then local paletteDef = Game.data.maps[mapId] - if PaletteFX.setGen2MapPalette(paletteDef and paletteDef.mapPalette or 0) + PaletteFX.setGen2FlashUsed(Game.save.flashLit == true) + if PaletteFX.setGen2MapPalette(require('src.render.Gen2PaletteContext').id(paletteDef) or 0,paletteDef) and PaletteFX.usesGen2BgPal() then MapLoader.invalidateAll() end @@ -2292,6 +2302,9 @@ end -- modes that do not use the ROM's palettes at all. function OverworldState:syncGen2Tod(tod) local PaletteFX = require("src.render.PaletteFX") + if GameVersion.isGen2() then + PaletteFX.bindGen2Context(self.map and self.map.def,Game and Game.save and Game.save.flashLit == true) + end if not PaletteFX.setGen2Tod(tod) then return end if not (GameVersion.isGen2() and PaletteFX.usesGen2BgPal()) then return end pcall(function() @@ -2339,7 +2352,10 @@ end function OverworldState:paletteNameFor(map) local palettes = FieldDefaults.field(Game.data, "palettes") - local name = map.def.palette or paletteLookup(palettes, map.id, map.def.tileset) + -- Gen2's PALETTE_* identity is not a Gen1/SGB colour-pack name. + local authored = map.def.palette + if GameVersion.isGen2() and type(authored)=='string'and authored:match('^PALETTE_')then authored=nil end + local name = authored or paletteLookup(palettes, map.id, map.def.tileset) if not name then -- Interiors inherit the outdoor map they sit in. Before the player has -- been outdoors at all, that is wLastMap's zero-fill -- map 0, @@ -2467,6 +2483,7 @@ end function OverworldState:setDark(on) on = on and true or false self.dark = on + if GameVersion.isGen2() then PaletteFX.setGen2FlashUsed(Game and Game.save and Game.save.flashLit == true) end if PaletteFX.setDarkWorld(on) and PaletteFX.bakesDarkness() and self.map then MapLoader.invalidateAll() self:reloadMap(self.map.id, "dark") diff --git a/tests/engine/gen2_palette_clock.lua b/tests/engine/gen2_palette_clock.lua index 1beb3dc13..c9d0dc01b 100644 --- a/tests/engine/gen2_palette_clock.lua +++ b/tests/engine/gen2_palette_clock.lua @@ -20,7 +20,7 @@ for _,v in ipairs({'gold','silver','crystal'})do P.setGen2Tod('MORN');P.setGen2MapPalette(1);P.setDarkWorld(true) T.eq(P.clockDaytime(),'NITE',v..' scene RTC query ignores map DAY and darkness') T.eq(P.daytimeFor({mapPalette=1},nil,true),'DAY',v..' map override still selects its own row') - T.eq(P.daytimeFor({mapPalette=1},nil,false),'DARK',v..' native darkness still wins before map override') + T.eq(P.daytimeFor({mapPalette=1},nil,false),'DAY',v..' another map does not inherit live cave darkness') T.eq(P.gen2Tod(),'DAY',v..' public clock query never changes live bake state') local Json=require('src.link.Json');local d=T.fixtures.fresh() local files={['mods/clock/manifest.json']=Json.encode({id='clock',name='Clock contract',version='1.0.0',api=2,entry='main.lua',affects_link=false}), diff --git a/tests/engine/gen2_palette_context.lua b/tests/engine/gen2_palette_context.lua new file mode 100644 index 000000000..270e60353 --- /dev/null +++ b/tests/engine/gen2_palette_context.lua @@ -0,0 +1,158 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes');local Data=require('src.core.Data') +local W=require('src.world.gen2.World');local TR=require('src.render.TileRenderer') +local Context=require('src.render.Gen2PaletteContext');local Assets=require('src.render.Assets') +local Map=require('src.world.Map');local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local saved={mode=P.mode,dark=P.darkWorld(),tod=P.gen2Tod(),palette=P.gen2MapPalette(),map=W.map,game=W.game,worldDark=W.dark, + image=Assets.image,imageData=Assets.imageData,loveImage=love.image,newImage=love.graphics.newImage} +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.setDark,i);if name=='Game'then slot=i;oldGame=value;break end end +assert(slot) +local function buffer(w,h,source) + local b={w=w,h=h,pixels={}} + function b:getDimensions()return self.w,self.h end + function b:getWidth()return self.w end + function b:getHeight()return self.h end + function b:getPixel(x,y) + local p=self.pixels[y*self.w+x] + if p then return unpack(p)end + local c=source and(1-x%4/3)or 0;return c,c,c,1 + end + function b:setPixel(x,y,...)self.pixels[y*self.w+x]={...}end + function b:setFilter()end + function b:setWrap()end + function b:release()end + return b +end +love.image={newImageData=function(w,h)return buffer(w,h)end} +love.graphics.newImage=function(value)assert(type(value)=='table');return value end +Assets.image=function()return buffer(16,8,true)end +Assets.imageData=function()return buffer(16,8,true)end +local function palette(seed) + local set={} + for slot=1,8 do set[slot]={};for c=1,4 do set[slot][c]={seed+slot+c,slot*10+c,c*20}end end + return set +end +local function fixture(version) + local d=T.fixtures.fresh();local rows={} + for i,row in ipairs({'MORN','DAY','NITE','DARK'})do rows[row]=palette(i*20)end + local ts={id='QA_CONTEXT',image='qa-context-'..version,tilesPerRow=2,palMap={0,6},palColorsByTod=rows, + animatedTiles={},blockCells=2,blocks={{0,1,0,1}},walkable={0},grassTiles={},waterTiles={}} + d.tilesets={QA_CONTEXT=ts} + d.maps={} + for id,p in pairs({AUTO=0,ROOM=1,NIGHT=2,MORNING=3,CAVE=4,UNUSED=7})do + d.maps[id]={id=id,label=id,width=1,height=1,blocks={0},tileset=ts.id,environment=1,mapPalette=p,group=1,number=1} + end + d.maps.CENTER={id='CENTER',label='CENTER',width=1,height=1,blocks={0},tileset=ts.id,environment=3,mapPalette=1,group=2,number=1} + d.field.gen2MapPalettes={rules={{kind='map',group=2,number=1,set='CENTER'},{kind='landmark',landmark=99,set='OTHER'}}, + sets={CENTER={DAY=palette(120)},OTHER={ALL=palette(160)}}} + d.field.gen2Roofs={tilesets={[ts.id]=true},byGroup={[1]=0},images={[0]='synthetic-roof'},slot=10,count=0,palIndex=6, + colors={[1]={DAY={{201,2,3},{204,5,6}},NITE={{211,12,13},{214,15,16}},DARK={{221,22,23},{224,25,26}}}}} + Data.publishGen2Palettes(d) + return d,ts,rows +end +local function samePixels(renderer,colors,label) + for tile,slot in ipairs({1,7})do + for c=1,4 do + local r,g,b=renderer.image:getPixel((tile-1)*8+c-1,0) + T.same({r,g,b},{colors[slot][c][1]/255,colors[slot][c][2]/255,colors[slot][c][3]/255},label..' native baked tile '..tile..' shade '..c) + end + end +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);P.setMode('gbc');P.setGen2Tod('NITE');P.setGen2MapPalette(1);P.setGen2FlashUsed(false);P.setDarkWorld(false) + local d,ts,rows=fixture(version) + T.eq(d.maps.AUTO.palette,'PALETTE_AUTO',version..' publishes native clock identity') + T.eq(d.maps.ROOM.palette,'PALETTE_DAY',version..' publishes forced room identity') + T.eq(rawget(d.maps.ROOM,'palette'),nil,version..' projection never serializes a copied palette field') + d.maps.ROOM.mapPalette=3;T.eq(d.maps.ROOM.palette,'PALETTE_MORN',version..' byte edit updates public identity immediately') + d.maps.ROOM.palette='PALETTE_DAY';T.eq(Context.id(d.maps.ROOM),1,version..' explicit authored identity remains authoritative') + d.maps.ROOM.mapPalette=1 + local authored={mapPalette=1,palette='CUSTOM'};Context.publish(authored);T.eq(authored.palette,'CUSTOM','authored non-native name preserved') + local meta={__index={other=7}};local custom=setmetatable({mapPalette=2},meta) + T.eq(Context.publish(custom),false,'authored lookup metatable is not replaced');T.eq(getmetatable(custom),meta,'authored metatable identity retained') + T.eq(P.daytimeFor(d.maps.ROOM,20,false),'DAY',version..' pinned room remains day') + T.eq(P.daytimeFor(d.maps.CAVE,12,false),'DARK',version..' neighbouring cave is dark before entry') + P.setDarkWorld(true) + T.eq(P.daytimeFor(d.maps.AUTO,12,false),'DAY',version..' outdoor neighbour cannot inherit current cave darkness') + T.eq(P.daytimeFor(d.maps.ROOM,20,false),'DAY',version..' pinned neighbour cannot inherit current cave darkness') + T.eq(P.daytimeFor(d.maps.CAVE,12,true),'NITE',version..' explicit Flash lights queried cave') + P.setDarkWorld(false) + P.setGen2MapPalette(4,d.maps.CAVE) + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' current-map query retains actual native presentation overrides') + T.eq(P.gen2RowFor(d.maps.ROOM),'DAY',version..' neighbour query still uses own header') + T.eq(P.daytimeFor(d.maps.AUTO,nil,false),'NITE',version..' absent explicit hour follows active native clock even in pinned current map') + P.setGen2MapPalette(1) + for id,row in pairs({AUTO='NITE',ROOM='DAY',NIGHT='NITE',MORNING='MORN',CAVE='DARK',UNUSED='NITE',CENTER='DAY'})do + local def=d.maps[id];local map=Map.new(def,ts) + local colors,chosen=P.bgSet(d.gen2Palettes,def,P.gen2RowFor(def)) + T.eq(chosen,row,version..' native header row '..id) + local renderer=TR.new(map,d) + T.eq(renderer.trueColor,true,version..' native constructor bakes actual colours '..id) + samePixels(renderer,colors,version..' '..id) + if id=='CENTER'then T.eq(colors[1][1][1],d.field.gen2MapPalettes.sets.CENTER.DAY[1][1][1],version..' Center special rule reaches public query')end + end + local before=rows.NITE[7][2] + local set=P.bgSet(d.gen2Palettes,d.maps.AUTO,'NITE') + T.same(set[7][2],{211,12,13},version..' native roof writes only imported middle pair') + T.eq(rows.NITE[7][2],before,version..' public roof query does not modify shared tileset') + T.eq(set[7][1],rows.NITE[7][1],version..' roof preserves endpoint zero') + T.eq(set[7][4],rows.NITE[7][4],version..' roof preserves endpoint three') + d.field.gen2Roofs.colors[1].NITE[1]={231,32,33} + T.same(P.bgSet(d.gen2Palettes,d.maps.AUTO,'NITE')[7][2],{231,32,33},version..' metadata view sees live roof replacements') + TR.invalidate() + samePixels(TR.new(Map.new(d.maps.AUTO,ts),d),P.bgSet(d,d.maps.AUTO,'NITE'),version..' native invalidation observes changed roof') + d.field.gen2MapPalettes.rules[1].set='MISSING';d.maps.CENTER.landmark=99 + T.eq(P.bgSet(d,d.maps.CENTER,'DAY')[1],rows.DAY[1],version..' unavailable first rule does not fall through to later match') + d.field.gen2MapPalettes.rules[1].set='CENTER' + local game={data=d,save={flags={}}};debug.setupvalue(W.setDark,slot,game) + W.map=Map.new(d.maps.CAVE,ts) + P.setGen2MapPalette(4,d.maps.CAVE) + -- Keep the actual presentation setter while controlling only reload IO. + local reload=W.reloadMap;W.reloadMap=function()end + W:setDark(true) + T.eq(W.daytime,'DARK',version..' public world reflects actual native darkness') + T.eq(W.flashUsed,false,version..' public Flash view reads canonical save state') + require('src.script.Gen2Commands') + require('src.script.Commands').g2_blinding_flash({game=game,save=game.save,overworld=W}) + T.eq(game.save.flashLit,true,version..' original native Flash writer sets actual save bit') + T.eq(W.flashUsed,true,version..' public Flash view follows native script write') + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' native neighbour context follows script Flash write') + T.eq(W.daytime,'NITE',version..' world view follows actual lit-cave palette bake') + W.reloadMap=reload + local oldView=d.gen2Palettes;Data.publishGen2Palettes(d) + T.eq(P.bgSet(oldView,d.maps.ROOM,'DAY'),nil,version..' retired published view cannot mix old/new metadata') + local files={['mods/context/manifest.json']=Json.encode({id='context',name='Synthetic context',version='1.0.0',api=2,entry='main.lua',affects_link=false}), + ['mods/context/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + mod.exports.query=function(world,map) + local row=Palettes.daytimeFor(map.def,20,world.flashUsed) + local current=world.map + local same=current.id==map.id + local clock=current.def.palette=='PALETTE_AUTO'and map.def.palette=='PALETTE_AUTO' + if(same or clock)and Palettes.DAYTIME_ID[world.daytime]then row=world.daytime end + return Palettes.bgSet(world.game.data.gen2Palettes,map.def,row),row + end + ]]} + local r=T.sdk.loadMods({'mods/context'},{data=d,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,version..' real Loader imports public atlas context') + W.game=game;W.map=Map.new(d.maps.AUTO,ts);P.setGen2MapPalette(0);P.setGen2Tod('NITE');W:setDark(false) + local query=r.loader.exports.context.query + local colors,row=query(W,Map.new(d.maps.ROOM,ts)) + T.eq(row,'DAY',version..' actual loaded public consumer keeps neighbouring room pinned') + T.same(colors,P.bgSet(d,d.maps.ROOM,'DAY'),version..' loaded consumer uses same native roof selection') + colors,row=query(W,W.map);T.eq(row,W.daytime,version..' loaded consumer uses actual current bake') + W:syncGen2Tod('NITE') + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' native clock sync rebinds canonical Flash after mod reload') + Loader.teardown();r.release() + T.eq(P.gen2RowFor(d.maps.CAVE),'DARK',version..' teardown retires Flash presentation state') +end +Assets.image,Assets.imageData=saved.image,saved.imageData;love.image=saved.loveImage;love.graphics.newImage=saved.newImage +W.map=saved.map;W.game=saved.game;W.dark=saved.worldDark;debug.setupvalue(W.setDark,slot,oldGame) +P.setMode(saved.mode);P.setGen2MapPalette(saved.palette);P.setGen2Tod(saved.tod);P.setDarkWorld(saved.dark);P.setGen2FlashUsed(false);V.set(original) +T.finish('native Gen2 map context, roof/special rows, world views and actual CPU atlas bake') From 75aa131dc16124819704419d1a42e9ed110ab072 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:01:35 +0100 Subject: [PATCH 039/187] Use shared native saved RTC for GSC world scripts and clock card --- docs/native-gen2-clock.md | 41 ++++++++++++++++ src/core/GameVersion.lua | 2 + src/core/Gen2Clock.lua | 81 +++++++++++++++++++++++++++++++ src/core/ModCompat.lua | 1 + src/mods/Loader.lua | 4 ++ src/pokemon/Evolution.lua | 11 +++-- src/render/PaletteFX.lua | 4 ++ src/script/Gen2Commands.lua | 14 +++--- src/script/Gen2Daily.lua | 2 +- src/ui/PokegearMenu.lua | 8 +-- src/world/OverworldController.lua | 13 +++-- 11 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 docs/native-gen2-clock.md create mode 100644 src/core/Gen2Clock.lua diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md new file mode 100644 index 000000000..bda4ee37a --- /dev/null +++ b/docs/native-gen2-clock.md @@ -0,0 +1,41 @@ +# Shared native Gen2 RTC + +The Pokégear previously displayed elapsed play time while world palettes, +object time masks and script variables used the host clock. The native +`Gen2Clock` now provides the same saved RTC-base reads to these consumers, +and the native happiness evolution gate. +The existing `src.core.gen2.Clock` name resolves to this native module. +`world.hour()`, `world.minute()` and `world.weekday()` read that same save. + +`setTime(save, hour, minute)` stores `rtc.startMinute`, an offset from the +host RTC, rather than a frozen hour. `setWeekday` stores `rtc.startDay` and +keeps the older native `g2DayOffset` field synchronized. Old saves without +an RTC base retain their host-hour and existing weekday-offset behavior. +Read calls never create clock data. Invalid/non-finite writes are rejected. +The normal native save codec preserves the offsets; play time is separate. + +World palettes, object-load period bits and `VAR_HOUR` share the saved hour; +the normal object-load freeze still applies. An explicit palette-query hour +retains precedence. The existing `world.tod` hook remains a presentation +override and does not rewrite the saved RTC. A raw scene-clock query uses +the current native save, independently of a room's forced palette. + +`clock.day_changed` is produced by actual weekday reads, including the live +world clock poll. It reports `day`, `previous` and `reason` (`rollover` or +`set`). A save's first observation is silent. Observations are per save, +reentrant-safe, subscription-aware and retired on mod/edition reload. +Native API 1/2 use the existing event dispatch boundary. The native GSC +producer remains generation-scoped. + +The clock card, native script readers, evolution time gate and world methods +are exercised directly in synthetic engine tests, including minute wrap, +weekly rollover, save roundtrip, RTC-versus-play-time display, the native callback +path and actual mod loading. Graphics IO alone is replaced for the clock +card. These checks do not establish a complete original-story clock-setting +sequence, DST, battery-backed SRAM RTC conversion, all daily scheduling or +fresh GPU acceptance for this change. Existing daily date keys still use +host calendar dates; lottery weekday selection now shares the saved weekday. + +The public contract is compared with the read-only Gen1Recomp reference; +the implementation is native fork code. No VASC implementation, private +assets, game text or ROM-derived clock data is included. diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 6c92886cf..ff6316b05 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1087,6 +1087,8 @@ function GameVersion.set(id) if previous~=GameVersion.current then local palette=package.loaded['src.render.PaletteFX'] if palette and palette.clearGen2Context then palette.clearGen2Context()end + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end end return GameVersion.current end diff --git a/src/core/Gen2Clock.lua b/src/core/Gen2Clock.lua new file mode 100644 index 000000000..fc02c2953 --- /dev/null +++ b/src/core/Gen2Clock.lua @@ -0,0 +1,81 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +-- Shared native GSC RTC reads. The public Clock spelling follows the +-- reference contract; no private mod implementation or clock is embedded. +local Clock = { MINUTES_PER_DAY = 1440, DAYS = 7, DEFAULT_HOUR = 10, DEFAULT_MINUTE = 0 } +local Strings = require("src.core.Strings") +Clock.DAY_NAMES = { "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY" } +local observed = setmetatable({}, { __mode = "k" }) +local function integer(value) + local n = tonumber(value) + if n and n == n and n ~= math.huge and n ~= -math.huge then return math.floor(n) end +end +local function rtc(save) + return type(save) == "table" and type(save.rtc) == "table" and save.rtc or nil +end +function Clock.hostMinutes() + return ((integer(os.date("%H")) or 0) * 60 + (integer(os.date("%M")) or 0)) % 1440 +end +function Clock.hostWeekday(now) + return (integer(os.date("%w", now)) or 0) % 7 +end +function Clock.minutes(save) + local state = rtc(save) + return (Clock.hostMinutes() + (state and integer(state.startMinute) or 0)) % 1440 +end +function Clock.hour(save) return math.floor(Clock.minutes(save) / 60) end +function Clock.minute(save) return Clock.minutes(save) % 60 end +function Clock.isSet(save) + local state = rtc(save) + return state ~= nil and integer(state.startMinute) ~= nil +end +function Clock.setTime(save, hour, minute) + if type(save) ~= "table" then return false end + hour, minute = integer(hour or 0), integer(minute or 0) + if not hour or not minute then return false end + save.rtc = rtc(save) or {} + save.rtc.startMinute = ((hour % 24) * 60 + minute % 60 - Clock.hostMinutes()) % 1440 + return true +end +local function note(save, day, reason) + if type(save) ~= "table" then return day end + local Runtime = require("src.mods.Runtime") + if not require("src.core.GameVersion").isGen2() or not Runtime.wants("clock.day_changed") then + observed[save] = nil + return day + end + local previous = observed[save] + observed[save] = day -- Commit before dispatch: reentrant reads cannot emit twice. + if previous ~= nil and previous ~= day then + Runtime.emit("clock.day_changed", { day = day, previous = previous, reason = reason }) + end + return day +end +function Clock.weekday(save, now) + local state = rtc(save) + local offset = state and integer(state.startDay) + if offset == nil then offset = type(save) == "table" and integer(save.g2DayOffset) or 0 end + return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover") +end +function Clock.setWeekday(save, day) + day = integer(day) + if type(save) ~= "table" or not day then return false end + save.rtc = rtc(save) or {} + day = day % 7 + save.rtc.startDay = (day - Clock.hostWeekday()) % 7 + save.rtc.dayOfWeek = day + -- Keep the existing native field in sync for older native consumers/saves. + save.g2DayOffset = save.rtc.startDay + note(save, day, "set") + return true +end +function Clock.weekdayName(day) + local name = Clock.DAY_NAMES[day] + return name and Strings(name) +end +function Clock.daytimeLabel(hour) + hour = integer(hour) or Clock.hour() + hour = hour % 24 + return Strings(hour < 4 and "NITE" or hour < 10 and "MORN" or hour < 18 and "DAY" or "NITE") +end +function Clock.resetObservations() observed = setmetatable({}, { __mode = "k" }) end +return Clock diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..2e92443a1 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -67,6 +67,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + ["src.core.gen2.Clock"] = "src.core.Gen2Clock", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index cd3ab6876..5aa2d364e 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1312,6 +1312,8 @@ end function Loader:load(data) local palette=package.loaded['src.render.PaletteFX'] if palette and palette.clearGen2Context then palette.clearGen2Context()end + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end -- 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 @@ -1657,6 +1659,8 @@ end function Loader.teardown() local palette=package.loaded['src.render.PaletteFX'] if palette and palette.clearGen2Context then palette.clearGen2Context()end + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 321387210..e9506843f 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -89,7 +89,7 @@ Evolution.METHODS = { return false end if not evo.timeOfDay then return true end - local night = Evolution.isNight() + local night = Evolution.isNight(game.save) if evo.timeOfDay == "NITE" then return night end return not night -- TR_MORNDAY: morn or day end, @@ -223,10 +223,11 @@ Evolution.HAPPINESS_TO_EVOLVE = 220 Evolution.BASE_HAPPINESS = 70 -- GSC time-of-day bands (constants/time_constants.asm): MORN 4-9, DAY --- 10-17, NITE 18-3. The port has no in-game clock, so the wall clock is --- what GSC's RTC would have been. -function Evolution.isNight() - local hour = tonumber(os.date("%H")) or 12 +-- 10-17, NITE 18-3. GSC reads the shared saved RTC offset; other editions +-- retain their existing host-clock behavior. +function Evolution.isNight(save) + local hour = require("src.core.GameVersion").isGen2() + and require("src.core.Gen2Clock").hour(save) or tonumber(os.date("%H")) or 12 return hour >= 18 or hour < 4 end diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index c95d9d31e..a0260e00b 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -561,6 +561,10 @@ function PaletteFX.gen2Tod() return gen2Tod end -- It must not inherit the live map override, darkness, or atlas bake state. function PaletteFX.clockDaytime(hour) hour = tonumber(hour) + if not hour and GameVersion.isGen2() then + local game = package.loaded["src.core.Game"] + hour = require("src.core.Gen2Clock").hour(game and game.save) + end if not hour then hour = tonumber(os.date("%H")) end hour = math.floor(hour or 12) % 24 if hour < 4 then return "NITE" end diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..d41e01d32 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2628,12 +2628,9 @@ function Commands.g2_readvar(ctx, var) elseif var == 6 then ctx.g2Var = countKeys(ctx.save.pokedex and ctx.save.pokedex.seen) elseif var == 10 then - ctx.g2Var = tonumber(os.date("%H")) or 0 + ctx.g2Var = require("src.core.Gen2Clock").hour(ctx.save) elseif var == 11 then - -- g2DayOffset is what mom's SetDayOfWeek picker answered, as a shift off - -- the host day, so Arthur still shows up on a real Thursday. - ctx.g2Var = ((tonumber(os.date("%w")) or 0) - + (ctx.save.g2DayOffset or 0)) % 7 + ctx.g2Var = require("src.core.Gen2Clock").weekday(ctx.save) elseif var == 16 then -- BoxFreeSpace counts only the current box, not all available PC slots. local Boxes = require("src.pokemon.Boxes") @@ -2898,7 +2895,8 @@ local WEEKDAYS = { -- either way the day still rolls over at real midnight instead of freezing. function Commands.g2_set_day_of_week(ctx) local game, runner = ctx.game, ctx.runner - local host = tonumber(os.date("%w")) or 0 + local Clock = require("src.core.Gen2Clock") + local current = Clock.weekday(ctx.save) local picked repeat Commands.show_text(ctx, "SetDayOfWeek.OakTimeWhatDayIsItText") @@ -2912,14 +2910,14 @@ function Commands.g2_set_day_of_week(ctx) -- no cancel: the ROM's loop watches A only, and onCancel is the only -- other path back to runner:resume() local menu = require("src.ui.Menu").new(game, items, { cancelable = false }) - menu.index = host + 1 + menu.index = current + 1 menu:clampScroll() game.stack:push(menu) runner:yield() Commands.ask(ctx, Strings(WEEKDAYS[picked + 1]) .. resolvedText(ctx, "SetDayOfWeek.OakTimeIsItText")) until ctx.lastCheck - ctx.save.g2DayOffset = (picked - host) % 7 + Clock.setWeekday(ctx.save, picked) end -- UnownPuzzle (SpecialsPointers row 41). The chamber scripts run diff --git a/src/script/Gen2Daily.lua b/src/script/Gen2Daily.lua index 96e54c666..6337f5cd9 100644 --- a/src/script/Gen2Daily.lua +++ b/src/script/Gen2Daily.lua @@ -45,7 +45,7 @@ end local function nextFriday(save, now) local date = os.date("*t", now) - local weekday = (date.wday - 1 + (tonumber(save.g2DayOffset) or 0)) % 7 + local weekday = require("src.core.Gen2Clock").weekday(save, now) local days = (5 - weekday) % 7 -- Match the weekday chosen at Mom's clock. if days == 0 then days = 7 end -- Calendar arithmetic, rather than seconds, also handles daylight saving. diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index f0da924a2..7d5cb0473 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -690,10 +690,10 @@ function PokegearMenu:drawClock() self:text("SWITCH", 13, 1) local save = self.game.save - local t = math.floor(require("src.core.SaveData").playSeconds(save)) - local hour = math.floor(t / 3600) % 24 - local minute = math.floor(t / 60) % 60 - local day = DAYS[(math.floor(t / 86400) % 7) + 1] + local Clock = require("src.core.Gen2Clock") + local minutes = Clock.minutes(save) + local hour, minute = math.floor(minutes / 60), minutes % 60 + local day = Clock.weekdayName(Clock.weekday(save) + 1) local display = hour % 12 if display == 0 then display = 12 end self:text(day, 6, 6) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 48fa02855..d996d5ce2 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -28,6 +28,7 @@ local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local ScriptRunner = require("src.script.ScriptRunner") local GameVersion = require("src.core.GameVersion") +local Gen2Clock = require("src.core.Gen2Clock") local Tilt = require("src.render.Tilt") local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") @@ -292,7 +293,7 @@ local function objectTodBit() local ow = Game and Game.overworld local bit = ow and tonumber(ow.objectTodBit) if bit then return bit end - local period = todPeriodAt(tonumber(os.date("%H")) or 12) + local period = todPeriodAt(Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil)) if period and tonumber(period.bit) then return tonumber(period.bit) end return TOD_BITS[objectTimeOfDay()] or TOD_BITS.DAY end @@ -2275,7 +2276,7 @@ local function sameTod(tod) return tod end -- < 4 NITE, < 10 MORN, < 18 DAY, else NITE. Gen 1 has no clock at all, so -- only Gen 2 consults the host one. local function clockTimeOfDay() - local hour = tonumber(os.date("%H")) or 12 + local hour = Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil) if hour < 4 then return "NITE" end if hour < 10 then return "MORNING" end if hour < 18 then return "DAY" end @@ -2288,7 +2289,7 @@ OverworldState.clockTimeOfDay = clockTimeOfDay -- The cartridge's MAPOBJECT_TIMEOFDAY bit for the hour on the clock right now. function OverworldState.clockTimeOfDayBit() - local period = todPeriodAt(tonumber(os.date("%H")) or 12) + local period = todPeriodAt(Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil)) return (period and tonumber(period.bit)) or TOD_BITS.DAY end @@ -2314,6 +2315,7 @@ function OverworldState:syncGen2Tod(tod) end function OverworldState:timeOfDay() + if GameVersion.isGen2() then Gen2Clock.weekday(Game and Game.save) end local tod = GameVersion.isGen2() and clockTimeOfDay() or (self.tod or "DAY") if not Runtime.wantsHook("world.tod") then if tod ~= self.tod then @@ -2350,6 +2352,11 @@ function OverworldState:timeOfDay() return self.tod end +-- Shared native saved RTC views; no second clock state on World. +function OverworldState.hour() return Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil) end +function OverworldState.minute() return Gen2Clock.minute(GameVersion.isGen2() and Game and Game.save or nil) end +function OverworldState.weekday() return Gen2Clock.weekday(GameVersion.isGen2() and Game and Game.save or nil) end + function OverworldState:paletteNameFor(map) local palettes = FieldDefaults.field(Game.data, "palettes") -- Gen2's PALETTE_* identity is not a Gen1/SGB colour-pack name. From d2cfa4dd52c9b89bb037db70bb799f50580f50c7 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:01:57 +0100 Subject: [PATCH 040/187] Include native RTC contract regression in upstream test archive --- tests/engine/gen2_clock.lua | 119 ++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/engine/gen2_clock.lua diff --git a/tests/engine/gen2_clock.lua b/tests/engine/gen2_clock.lua new file mode 100644 index 000000000..e3085943c --- /dev/null +++ b/tests/engine/gen2_clock.lua @@ -0,0 +1,119 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local V = require('src.core.GameVersion');local version = V.get() +require('src.core.ModCompat').install() +local Clock = require('src.core.gen2.Clock') +local P = require('src.render.PaletteFX');local W = require('src.world.OverworldController') +require('src.script.Gen2Commands');local Commands = require('src.script.Commands');local Loader = require('src.mods.Loader') +local Json = require('src.link.Json');local Save = require('src.core.SaveData') +local date = os.date;local host = { hour=13, minute=20, day=2 } +os.date = function(format, now) + if now then return date(format,now) end + if format=='%H' then return tostring(host.hour) end + if format=='%M' then return tostring(host.minute) end + if format=='%w' then return tostring(host.day) end + return date(format,now) +end +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.timeOfDay,i) + if name=='Game' then slot=i;oldGame=value;break end + if not name then break end +end +assert(slot) +local nativeGame=require('src.core.Game');local oldSave=nativeGame.save +T.eq(Clock,require('src.core.Gen2Clock'),'public name shares actual native clock') +T.eq(Clock.minutes(),800,'no save reads host RTC') +T.eq(Clock.isSet({}),false,'old save has no fabricated clock anchor') +local malformed={rtc='invalid'} +T.eq(Clock.hour(malformed),13,'malformed optional clock falls back to host') +T.eq(Clock.setTime(malformed,0/0,10),false,'NaN cannot poison saved clock') +T.eq(malformed.rtc,'invalid','refused write is atomic') +T.eq(Clock.setTime(false,10,0),false,'no saved state means no write') +T.eq(Clock.setWeekday({},math.huge),false,'infinite weekday rejected') +T.eq(Clock.weekdayName(0),nil,'weekday label API is one-based') +for _,edition in ipairs({'gold','silver','crystal'}) do + V.set(edition);host.hour=13;host.minute=20;host.day=2 + local save={playTime=0,g2DayOffset=3};local untouched=Json.encode(save) + T.eq(Clock.hour(save),13,edition..' legacy hour retains host behavior') + T.eq(Clock.weekday(save),5,edition..' legacy native weekday offset retained') + T.eq(Json.encode(save),untouched,edition..' clock reads do not seed saved state') + T.eq(Clock.setTime(save,23,55),true,edition..' clock setting anchors native RTC') + T.eq(Clock.setWeekday(save,6),true,edition..' day picker stores native RTC base') + T.eq(Clock.hour(save),23,edition..' saved hour is observed immediately') + T.eq(Clock.minute(save),55,edition..' saved minute is observed immediately') + T.eq(Clock.weekday(save),6,edition..' weekday is observed immediately') + T.eq(Clock.isSet(save),true,edition..' explicit clock setting is distinguishable') + T.eq(save.g2DayOffset,save.rtc.startDay,edition..' old native day readers retain shared offset') + host.minute=30 + T.eq(Clock.minutes(save),5,edition..' RTC keeps ticking and wraps at midnight') + host.day=3 + T.eq(Clock.weekday(save),0,edition..' saved weekday follows RTC weekly rollover') + local round=assert(Save.decode(Save.encode(save))) + T.eq(Clock.minutes(round),5,edition..' save roundtrip retains RTC minute base') + T.eq(Clock.weekday(round),0,edition..' save roundtrip retains RTC weekday base') + local game={data=T.fixtures.fresh(),save=save} + debug.setupvalue(W.timeOfDay,slot,game);nativeGame.save=save + local world=setmetatable({map={id='RTC',def={mapPalette=0}},player={cellX=0,cellY=0}}, {__index=W}) + -- Control only graphics IO; native clock, period selection and commands run. + world.syncGen2Tod=function()end + for _,case in ipairs({{3,59,'NITE',4},{4,0,'MORNING',1},{9,59,'MORNING',1}, + {10,0,'DAY',2},{17,59,'DAY',2},{18,0,'NITE',4}})do + Clock.setTime(save,case[1],case[2]) + T.eq(world:timeOfDay(),case[3],edition..' native world reads saved boundary '..case[1]) + T.eq(W.clockTimeOfDayBit(),case[4],edition..' object-load bit uses same saved boundary '..case[1]) + T.eq(world.hour(),case[1],edition..' legacy world dot hour agrees') + T.eq(world:minute(),case[2],edition..' legacy world colon minute agrees') + T.eq(P.clockDaytime(),case[3]=='MORNING' and 'MORN' or case[3],edition..' scene palette raw RTC agrees') + T.eq(P.clockDaytime(12),'DAY',edition..' explicit scene hour remains authoritative') + local ctx={save=save,overworld=world} + Commands.g2_readvar(ctx,10);T.eq(ctx.g2Var,case[1],edition..' actual VAR_HOUR agrees') + Commands.g2_readvar(ctx,11);T.eq(ctx.g2Var,Clock.weekday(save),edition..' actual VAR_WEEKDAY agrees') + Commands.g2_checktime(ctx,case[4]);T.eq(ctx.lastCheck,true,edition..' actual checktime agrees') + T.eq(require('src.pokemon.Evolution').isNight(save),case[4]==4,edition..' happiness time gate agrees') + end + -- Native clock-card composition must show RTC, even with zero play time. + Clock.setTime(save,21,47);Clock.setWeekday(save,4) + local text={};local Gear=require('src.ui.PokegearMenu') + local gear=setmetatable({game=game,blink=0,gfx={cards={clock={}}}}, {__index=Gear}) + for _,method in ipairs({'drawTilemap','drawStrip','textbox','printBoxText','drawModeArrow'})do gear[method]=function()end end + gear.text=function(_,value,x,y)text[x..':'..y]=value end + gear:drawClock() + T.eq(text['6:6'],'THURSDAY',edition..' actual clock card shows selected weekday') + T.eq(text['6:8'],' 9',edition..' actual clock card shows RTC hour rather than play hours') + T.eq(text['9:8'],'47',edition..' actual clock card shows RTC minute rather than play minutes') + T.eq(text['12:8'],'PM',edition..' actual clock card shows RTC half-day') + for _,mode in ipairs({{1,'native'},{2,'native'}}) do + local files={['mods/rtc/manifest.json']=Json.encode({id='rtc',name='Native RTC observer',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/rtc/main.lua']=[[ + local mod=...; + local Clock=require('src.core.gen2.Clock');mod.exports.events={} + mod.exports.stop=mod.events:on("clock.day_changed",function(e) + mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} + assert(Clock.weekday(mod.exports.save)==e.day) + e.day=99;return false + end) + ]]} + local result=T.sdk.loadMods({'mods/rtc'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + local label=edition..'/'..mode[1]..'/'..mode[2] + T.eq(#result.errors,0,label..' actual loader admits public clock module/event') + local e=result.loader.exports.rtc;local own={};e.save=own + Clock.weekday(own);T.eq(#e.events,0,label..' first read is not a fictitious rollover') + host.day=(host.day+1)%7 + Clock.weekday(own);T.eq(#e.events,1,label..' real read observes rollover exactly once') + T.eq(e.events[1].reason,'rollover',label..' rollover reason supplied') + Clock.weekday(own);T.eq(#e.events,1,label..' reentrant/repeated reads do not repeat event') + Clock.weekday({});T.eq(#e.events,1,label..' another profile starts silently') + local selected=(Clock.weekday(own)+2)%7 + Clock.setWeekday(own,selected) + T.eq(#e.events,2,label..' native day setter observes one change') + T.eq(e.events[2].reason,'set',label..' explicit setter reason supplied') + T.eq(Clock.weekday(own),selected,label..' listener mutation cannot corrupt native weekday') + e.stop();host.day=(host.day+1)%7;Clock.weekday(own) + T.eq(#e.events,2,label..' unsubscribe retires actual observation') + Loader.teardown();result.release() + end +end +nativeGame.save=oldSave;debug.setupvalue(W.timeOfDay,slot,oldGame) +Clock.resetObservations();os.date=date;V.set(version) +T.finish('shared native Gen2 saved RTC, actual world/scripts/clock-card and public event') From 5cd6128762e71ec87574a50743567990d12a7804 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:06:35 +0100 Subject: [PATCH 041/187] Resolve AUTO neighbour palettes from shared RTC outside pinned scenes --- docs/native-gen2-clock.md | 4 ++++ docs/native-gen2-palette-context.md | 7 ++++--- src/render/PaletteFX.lua | 14 ++++++++++---- tests/engine/gen2_palette_context.lua | 11 +++++++++-- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md index bda4ee37a..ae52c27d1 100644 --- a/docs/native-gen2-clock.md +++ b/docs/native-gen2-clock.md @@ -19,6 +19,10 @@ the normal object-load freeze still applies. An explicit palette-query hour retains precedence. The existing `world.tod` hook remains a presentation override and does not rewrite the saved RTC. A raw scene-clock query uses the current native save, independently of a room's forced palette. +AUTO neighbours share the effective clock presentation while the entered +map also follows AUTO. From a pinned room/cave, an AUTO neighbour instead +uses the saved RTC; that room's unused world-TOD hook is not a neighbour +palette override. This keeps explicit native/external hour queries aligned. `clock.day_changed` is produced by actual weekday reads, including the live world clock poll. It reports `day`, `previous` and `reason` (`rollover` or diff --git a/docs/native-gen2-palette-context.md b/docs/native-gen2-palette-context.md index 68450c9e8..bda03ba07 100644 --- a/docs/native-gen2-palette-context.md +++ b/docs/native-gen2-palette-context.md @@ -22,9 +22,10 @@ queries retain their prior live-state fallback. The native renderer uses neighbour's own header and the active clock/Flash presentation state. Native map entry and Flash update that state; atlas cache keys include the selected row. Animated overdraw receives the same selected palette as the static bake. -An omitted hour uses the entered world's native clock row, including its -`world.tod` selection; outside an entered context it keeps the host-clock -fallback. An explicit hour remains authoritative. Mod load/unload and edition +An omitted hour shares the entered world's `world.tod` selection when +that scene follows AUTO. From a pinned room/cave, AUTO neighbours instead +read the saved RTC. Outside an entered context the query reads that RTC +without inheriting a map presentation override. An explicit hour remains authoritative. Mod load/unload and edition changes retire the entered context, so returning to the launcher cannot revive a previous world's clock selection. diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index a0260e00b..52c994215 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -497,14 +497,20 @@ function PaletteFX.bindGen2Context(def,flashUsed) end function PaletteFX.setGen2FlashUsed(on)gen2FlashUsed=on==true end function PaletteFX.effectiveGen2Tod()return darkWorld and 'DARK'or gen2Tod end --- A neighbour uses its own header, the native hooked clock and the same Flash --- presentation state. It must not inherit the current map's forced DAY row. +-- AUTO neighbours share the entered AUTO scene's presentation override. +-- A pinned current room/cave has no outdoor clock presentation to share: +-- neighbouring AUTO maps read the saved RTC instead of its unused hook row. +local function gen2NeighbourClock() + local id=require('src.render.Gen2PaletteContext').id(gen2CurrentDef) + if GEN2_MAP_PALETTE_ROW[id] then return PaletteFX.clockDaytime() end + return gen2ClockTod +end function PaletteFX.gen2RowFor(def) if def~=nil and def==gen2CurrentDef then return PaletteFX.effectiveGen2Tod()end local id=require('src.render.Gen2PaletteContext').id(def) if id==nil then return PaletteFX.effectiveGen2Tod() end if id==4 then return gen2FlashUsed and 'NITE'or 'DARK'end - return GEN2_MAP_PALETTE_ROW[id]or gen2ClockTod + return GEN2_MAP_PALETTE_ROW[id]or gen2NeighbourClock() end local function gen2RecomputeTod() @@ -587,7 +593,7 @@ function PaletteFX.daytimeFor(mapDef, hour, flashUsed) local id=require('src.render.Gen2PaletteContext').id(mapDef) if id~=nil then if id==4 then return flashUsed==true and 'NITE'or 'DARK'end - return GEN2_MAP_PALETTE_ROW[id]or(hour==nil and gen2CurrentDef and gen2ClockTod)or PaletteFX.clockDaytime(hour) + return GEN2_MAP_PALETTE_ROW[id]or(hour==nil and gen2CurrentDef and gen2NeighbourClock())or PaletteFX.clockDaytime(hour) end -- Darkness wins outright and the clock stops mattering, which is what -- .NeedsFlash does: an unlit cave is the DARKNESS row at noon. diff --git a/tests/engine/gen2_palette_context.lua b/tests/engine/gen2_palette_context.lua index 270e60353..34082161f 100644 --- a/tests/engine/gen2_palette_context.lua +++ b/tests/engine/gen2_palette_context.lua @@ -9,7 +9,8 @@ local W=require('src.world.gen2.World');local TR=require('src.render.TileRendere local Context=require('src.render.Gen2PaletteContext');local Assets=require('src.render.Assets') local Map=require('src.world.Map');local Loader=require('src.mods.Loader');local Json=require('src.link.Json') local saved={mode=P.mode,dark=P.darkWorld(),tod=P.gen2Tod(),palette=P.gen2MapPalette(),map=W.map,game=W.game,worldDark=W.dark, - image=Assets.image,imageData=Assets.imageData,loveImage=love.image,newImage=love.graphics.newImage} + image=Assets.image,imageData=Assets.imageData,loveImage=love.image,newImage=love.graphics.newImage,date=os.date} +os.date=function(format,...)if format=='%H'then return '20'end;return saved.date(format,...)end local slot,oldGame for i=1,100 do local name,value=debug.getupvalue(W.setDark,i);if name=='Game'then slot=i;oldGame=value;break end end assert(slot) @@ -86,7 +87,12 @@ for _,version in ipairs({'gold','silver','crystal'})do P.setGen2MapPalette(4,d.maps.CAVE) T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' current-map query retains actual native presentation overrides') T.eq(P.gen2RowFor(d.maps.ROOM),'DAY',version..' neighbour query still uses own header') - T.eq(P.daytimeFor(d.maps.AUTO,nil,false),'NITE',version..' absent explicit hour follows active native clock even in pinned current map') + T.eq(P.daytimeFor(d.maps.AUTO,nil,false),'NITE',version..' absent hour reads RTC outside a pinned current map') + P.setGen2Tod('MORN');P.setGen2MapPalette(1,d.maps.ROOM) + T.eq(P.gen2RowFor(d.maps.AUTO),'NITE',version..' pinned room cannot pass an unused morning override to outdoor neighbours') + P.setGen2MapPalette(0,d.maps.AUTO) + T.eq(P.gen2RowFor({mapPalette=0}),'MORN',version..' outdoor neighbours still share current AUTO presentation') + P.setGen2Tod('NITE') P.setGen2MapPalette(1) for id,row in pairs({AUTO='NITE',ROOM='DAY',NIGHT='NITE',MORNING='MORN',CAVE='DARK',UNUSED='NITE',CENTER='DAY'})do local def=d.maps[id];local map=Map.new(def,ts) @@ -153,6 +159,7 @@ for _,version in ipairs({'gold','silver','crystal'})do T.eq(P.gen2RowFor(d.maps.CAVE),'DARK',version..' teardown retires Flash presentation state') end Assets.image,Assets.imageData=saved.image,saved.imageData;love.image=saved.loveImage;love.graphics.newImage=saved.newImage +os.date=saved.date W.map=saved.map;W.game=saved.game;W.dark=saved.worldDark;debug.setupvalue(W.setDark,slot,oldGame) P.setMode(saved.mode);P.setGen2MapPalette(saved.palette);P.setGen2Tod(saved.tod);P.setDarkWorld(saved.dark);P.setGen2FlashUsed(false);V.set(original) T.finish('native Gen2 map context, roof/special rows, world views and actual CPU atlas bake') From a11d4e9af1e6210070de49e4e3c35fbb63be5198 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:25:36 +0100 Subject: [PATCH 042/187] Expose native clock card parts to existing external presenters --- docs/native-gen2-clock.md | 5 +++ src/ui/PokegearMenu.lua | 26 +++++++++--- tests/engine/gen2_clock_card.lua | 68 ++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 tests/engine/gen2_clock_card.lua diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md index ae52c27d1..0bf1f6f38 100644 --- a/docs/native-gen2-clock.md +++ b/docs/native-gen2-clock.md @@ -19,6 +19,11 @@ the normal object-load freeze still applies. An explicit palette-query hour retains precedence. The existing `world.tod` hook remains a presentation override and does not rewrite the saved RTC. A raw scene-clock query uses the current native save, independently of a room's forced palette. +`PokegearMenu:clockParts()` exposes hour/minute/one-based UI weekday from +the live native world or shared saved clock. The built-in card and external +presenters use this same getter, retaining authored card-clock overrides. +This prevents an external presenter from showing its fallback zero clock +while the internal RTC and built-in text are already correct. AUTO neighbours share the effective clock presentation while the entered map also follows AUTO. From a pinned room/cave, an AUTO neighbour instead uses the saved RTC; that room's unused world-TOD hook is not a neighbour diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index 7d5cb0473..a87e3392d 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -370,6 +370,25 @@ function PokegearMenu:current() return self.cards[self.index] end +-- Public clock presentation, shared by the native card and source renderers. +-- Keep the native save/world as the owner; weekday is one-based for UI lists. +function PokegearMenu:clockParts() + if type(self.clock) == "table" then + return self.clock.hour or 0, self.clock.minute or 0, self.clock.weekday or 1 + end + local game = self.game + local world = game and (game.overworld or game.world) + if world and type(world.hour) == "function" and type(world.minute) == "function" + and type(world.weekday) == "function" then + return world:hour(), world:minute(), world:weekday() % 7 + 1 + end + local save = game and game.save or self.save + local Clock = require("src.core.Gen2Clock") + local minutes = Clock.minutes(save) + return math.floor(minutes / 60), minutes % 60, Clock.weekday(save) + 1 +end + + -- The registered phone numbers, newest id last. -- -- Gen2 keeps two halves of this and the card used to read neither: which ids @@ -689,11 +708,8 @@ function PokegearMenu:drawClock() self:drawStrip() self:text("SWITCH", 13, 1) - local save = self.game.save - local Clock = require("src.core.Gen2Clock") - local minutes = Clock.minutes(save) - local hour, minute = math.floor(minutes / 60), minutes % 60 - local day = Clock.weekdayName(Clock.weekday(save) + 1) + local hour, minute, weekday = self:clockParts() + local day = require("src.core.Gen2Clock").weekdayName(weekday) local display = hour % 12 if display == 0 then display = 12 end self:text(day, 6, 6) diff --git a/tests/engine/gen2_clock_card.lua b/tests/engine/gen2_clock_card.lua new file mode 100644 index 000000000..a36d76005 --- /dev/null +++ b/tests/engine/gen2_clock_card.lua @@ -0,0 +1,68 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local Gear=require('src.ui.gen2.Pokegear');local Clock=require('src.core.Gen2Clock') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local date=os.date +os.date=function(format,...)if format=='%H'then return '12'end;if format=='%M'then return '05'end;if format=='%w'then return '1'end;return date(format,...)end +local function parts(screen)return {screen:clockParts()}end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition) + local save={playTime=0};Clock.setTime(save,21,47);Clock.setWeekday(save,4) + local game={save=save,data=T.fixtures.fresh()} + local gear=setmetatable({game=game},{__index=Gear}) + local before=Json.encode(save) + T.same(parts(gear),{21,47,5},edition..' external card reads shared saved RTC, weekday one-based') + T.eq(Json.encode(save),before,edition..' clock presentation does not mutate saved state') + Clock.setTime(save,7,13);Clock.setWeekday(save,0) + T.same(parts(gear),{7,13,1},edition..' native clock edits are immediately visible') + local seen={};local current={hour=22,minute=8,day=6} + game.overworld={hour=function(self)seen.hour=self;return current.hour end, + minute=function(self)seen.minute=self;return current.minute end, + weekday=function(self)seen.weekday=self;return current.day end} + T.same(parts(gear),{22,8,7},edition..' live native world owns presentation') + T.eq(seen.hour,game.overworld,edition..' actual world hour receives its owner') + T.eq(seen.minute,game.overworld,edition..' actual world minute receives its owner') + T.eq(seen.weekday,game.overworld,edition..' actual world weekday receives its owner') + current.hour,current.minute,current.day=4,59,0 + T.same(parts(gear),{4,59,1},edition..' live world changes are not cached as UI state') + game.world=game.overworld;game.overworld=nil + T.same(parts(gear),{4,59,1},edition..' reference game.world spelling uses same read protocol') + game.overworld={hour=function()error('partial clock must not be mixed with saved clock')end} + T.same(parts(gear),{7,13,1},edition..' incomplete world falls back to one complete saved clock') + gear.clock={hour=10,minute=34,weekday=3} + T.same(parts(gear),{10,34,3},edition..' authored card clock override retains precedence') + gear.clock={} + T.same(parts(gear),{0,0,1},edition..' authored empty override retains reference defaults') + gear.clock=nil;game.overworld=nil;game.world=nil + local detached=setmetatable({save=save},{__index=Gear}) + T.same(parts(detached),{7,13,1},edition..' save-only reference card is supported') + -- The native draw and an unchanged external call use the same getter. + local values={};gear.blink=0;gear.gfx={cards={clock={}}} + for _,method in ipairs({'drawTilemap','drawStrip','textbox','printBoxText','drawModeArrow'})do gear[method]=function()end end + gear.clockParts=function()return 23,56,6 end + gear.text=function(_,value,x,y)values[x..':'..y]=value end + gear:drawClock() + T.eq(values['6:6'],'FRIDAY',edition..' native card uses public weekday projection') + T.eq(values['6:8'],'11',edition..' native card uses public hour projection') + T.eq(values['9:8'],'56',edition..' native card uses public minute projection') + T.eq(values['12:8'],'PM',edition..' native card keeps cartridge half-day display') + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/card/manifest.json']=Json.encode({id='card',name='Native clock card reader',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/card/main.lua']=[[ + local mod=...;local Gear=require('src.ui.gen2.Pokegear') + mod.exports.read=function(game) + local screen=setmetatable({game=game},{__index=Gear}) + return screen:clockParts() + end + ]]} + local result=T.sdk.loadMods({'mods/card'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + local label=edition..'/'..mode[1]..'/'..mode[2] + T.eq(#result.errors,0,label..' public clock-card consumer loads unchanged') + T.same({result.loader.exports.card.read(game)},{7,13,1},label..' real loaded consumer reads native RTC rather than fallback zeroes') + Loader.teardown();result.release() + end +end +Clock.resetObservations();os.date=date;V.set(version) +T.finish('native and external Gen2 clock-card presentation over shared RTC') From ccdf628b6ce21e02c252e0dcf4f1ff7ffe647b45 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:55:56 +0100 Subject: [PATCH 043/187] Add native Pokemon palette queries and typed indexed draw compatibility --- docs/native-gen2-pokemon-palettes.md | 47 ++++++++ src/core/ModCompat.lua | 3 +- src/render/Gen2PaletteDraw.lua | 114 ++++++++++++++++++ src/render/Gen2PokemonPalettes.lua | 61 ++++++++++ src/render/PaletteFX.lua | 10 ++ tests/engine/gen2_palette_draw.lua | 105 ++++++++++++++++ tests/engine/gen2_pokemon_palette_queries.lua | 71 +++++++++++ tests/gpu/gen2_palette_draw.lua | 77 ++++++++++++ 8 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 docs/native-gen2-pokemon-palettes.md create mode 100644 src/render/Gen2PaletteDraw.lua create mode 100644 src/render/Gen2PokemonPalettes.lua create mode 100644 tests/engine/gen2_palette_draw.lua create mode 100644 tests/engine/gen2_pokemon_palette_queries.lua create mode 100644 tests/gpu/gen2_palette_draw.lua diff --git a/docs/native-gen2-pokemon-palettes.md b/docs/native-gen2-pokemon-palettes.md new file mode 100644 index 000000000..98e69c1f8 --- /dev/null +++ b/docs/native-gen2-pokemon-palettes.md @@ -0,0 +1,47 @@ +# Native Gen2 Pokemon palette drawing + +`src.world.gen2.Palettes.monColors(data, species, shiny)` reads the existing +native Pokemon/palette records. It accepts a full native dataset, its current +published `gen2Palettes` view, or reference palette-only normal/shiny pairs. +Native species keys resolve to the same native records. Existing dataset +aliases are honoured; this patch does not install another species catalogue. Four detached 0–255 RGB triples are returned; missing or +invalid colours return `nil`. Retired dataset views cannot revive old data. +The built-in `PaletteFX.monPal` uses the same native selection without copying +its source palette. Transformed Pokemon retain their existing precedence. + +`src.render.GbcPalette` now resolves to `src.render.Gen2PaletteDraw`, rather +than the unrelated LCD post-process `src.render.GBCFX`. Its indexed-image +shader recovers the four source grayscale shades and substitutes the supplied +palette while preserving texture alpha and drawing tint. `use`, `with`, +`useKeyed`, `keyedWith`, `useRaw`, `withRaw`, `shader`, `keyedShader`, +`available`, `resolve`, `color` and `clear` are provided. Keyed drawing makes +source shade zero transparent. Normal drawing resolves native `PaletteFX` +display/register state; raw drawing bypasses that resolution. + +Scoped drawing restores both the previous shader object and managed palette +uniforms, including nested calls, callback failures and partial upload errors. +Public calls upload all four rows because callers can modify exposed shader +objects directly. No private renderer or ROM-derived colour catalogue is +embedded in the implementation. + +## Validation and remaining work + +ROM-free suites cover public Loader consumers, Gold/Silver/Crystal species +queries, native source identity, shiny/normal/transformed precedence, authored +changes, detached results, retired views, invalid values and graphics failure +paths. The GPU probe `tests/gpu/gen2_palette_draw.lua` compares actual canvas +pixels for every shade, alpha, tint, keying, native register remap, nested +success/error and foreign shader restoration on each tested backend. + +Real-ROM validation must additionally compare the native palette producer and +public queries with an independent decoder, using actual imported Pokemon +metadata. Those private inputs and outputs are excluded from the repository. +A successful pixel probe is not proof of every unchanged mod presentation or +full gameplay acceptance. + +The complete reference `GbcPalette` API is not yet implemented: its separate +mode/custom-ramp controls and backwards remapping of already coloured frames +still need integration with native display state. The LCD effects API remains +separate. Full colour art, UI layout, original story admission, broader hardware +and complete Gen1/VASC parity remain independent acceptance work. No VASC code +or assets are changed by this feature. diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 2e92443a1..420182efc 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -98,7 +98,8 @@ ModCompat.ALIASES = { -- the Gold port splits its Game class per generation; this one does not ["src.core.Game2"] = "src.core.Game", - ["src.render.GbcPalette"] = "src.render.GBCFX", + -- Indexed image palettes and LCD screen effects are different contracts. + ["src.render.GbcPalette"] = "src.render.Gen2PaletteDraw", } local seen = {} diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua new file mode 100644 index 000000000..b0c271a45 --- /dev/null +++ b/src/render/Gen2PaletteDraw.lua @@ -0,0 +1,114 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Indexed image drawing, separate from the LCD post-process in GBCFX. +-- Palette resolution uses the native display/register state in PaletteFX. +local Palettes = require("src.render.PaletteFX") +local M = {} +local compiler, shaders, failed = nil, {}, {} +local sent = setmetatable({}, {__mode = "k"}) +local names = {"pal0", "pal1", "pal2", "pal3"} +local source = [[ +extern vec3 pal0; extern vec3 pal1; extern vec3 pal2; extern vec3 pal3; +vec4 effect(vec4 tint, Image image, vec2 uv, vec2 screen) { + vec4 pixel = Texel(image, uv); + float shade = floor((1.0 - pixel.r) * 3.0 + 0.5); + vec3 rgb = shade < 0.5 ? pal0 : (shade < 1.5 ? pal1 : (shade < 2.5 ? pal2 : pal3)); + return vec4(rgb, ALPHA) * tint; +} +]] + +local function graphics() return love and love.graphics end +local function shader(keyed) + local g = graphics() + local newShader = g and g.newShader + if type(newShader) ~= "function" then return nil end + if compiler ~= newShader then + compiler, shaders, failed = newShader, {}, {} + end + local key = keyed and "keyed" or "normal" + if failed[key] then return nil end + if not shaders[key] then + local alpha = keyed and "(shade < 0.5 ? 0.0 : pixel.a)" or "pixel.a" + local ok, value = pcall(newShader, (source:gsub("ALPHA", alpha))) + if not ok or not value then failed[key] = true; return nil end + shaders[key] = value + end + return shaders[key] +end +function M.shader() return shader(false) end +function M.keyedShader() return shader(true) end +function M.available() return M.shader() ~= nil end + +local function copy(colors) + if type(colors) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + local color = colors[i] + if type(color) ~= "table" then return nil end + local row = {} + for c = 1, 3 do + local n = color[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + row[c] = n + end + out[i] = row + end + return out +end +function M.resolve(colors) return Palettes.effectiveColors(colors) end +function M.color(colors, index) + local palette = M.resolve(colors) + return palette and palette[index] or nil +end + +local function upload(sh, colors) + -- Public shader objects can be edited externally. Upload every public call; + -- a cached last-send comparison could otherwise trust foreign uniforms. + sent[sh] = nil + for i = 1, 4 do + local row = colors[i] + sh:send(names[i], {row[1]/255, row[2]/255, row[3]/255}) + end + sent[sh] = colors +end +local function use(colors, keyed, raw) + local g = graphics() + if not (g and g.setShader) then return false end + colors = copy(raw and colors or M.resolve(colors)) + if not colors then return false end + local sh = shader(keyed) + if not sh then return false end + upload(sh, colors) + g.setShader(sh) + return true +end +function M.use(colors) return use(colors, false, false) end +function M.useRaw(colors) return use(colors, false, true) end +function M.useKeyed(colors) return use(colors, true, false) end +function M.clear() + local g = graphics() + if g and g.setShader then g.setShader() end +end + +local function scoped(colors, body, apply) + local g = graphics() + local previous = g and g.getShader and g.getShader() or nil + -- Restoring only the shader object is insufficient: nested calls reuse it + -- and change its uniforms. Save the palette values as well as its identity. + local previousColors = previous and sent[previous] + local ok, applied = pcall(function() + local active = apply(colors) + body() + return active + end) + local restored, why = pcall(function() + if previousColors then upload(previous, previousColors) end + if g and g.setShader then g.setShader(previous) end + end) + if not ok then error(applied, 0) end + if not restored then error(why, 0) end + return applied +end +function M.with(colors, body) return scoped(colors, body, M.use) end +function M.withRaw(colors, body) return scoped(colors, body, M.useRaw) end +function M.keyedWith(colors, body) return scoped(colors, body, M.useKeyed) end +return M diff --git a/src/render/Gen2PokemonPalettes.lua b/src/render/Gen2PokemonPalettes.lua new file mode 100644 index 000000000..576e29589 --- /dev/null +++ b/src/render/Gen2PokemonPalettes.lua @@ -0,0 +1,61 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Public Gen2 colour reads over the native palette pack. No duplicate +-- species catalogue, ROM colours or private renderer implementation. +local Context = require("src.render.Gen2PaletteContext") +local M = {} + +-- Return the native source table without allocation for the built-in renderer. +-- Public consumers receive detached RGB values through colors() below. +function M.native(data, species, shiny) + local owner = Context.owner(data) + local pokemon = owner and owner.pokemon + local def = type(pokemon) == "table" and pokemon[species] + local pack = owner and owner.palettes + local palettes = type(pack) == "table" and pack.palettes + if type(def) ~= "table" or type(palettes) ~= "table" then return nil end + if shiny and def.shinyPalette and palettes[def.shinyPalette] then + return palettes[def.shinyPalette] + end + if def.palette and palettes[def.palette] then return palettes[def.palette] end + local names = pack.pokemon + local name = type(names) == "table" and (names[def.id or species] or names[species]) + return name and palettes[name] or nil +end + +local function color(value) + if type(value) ~= "table" then return nil end + local rgb = {} + for i = 1, 3 do + local n = value[i] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + rgb[i] = n + end + return rgb +end +local function detached(palette) + if type(palette) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + out[i] = color(palette[i]) + if not out[i] then return nil end + end + return out +end + +function M.colors(data, species, shiny) + if type(data) ~= "table" or type(species) ~= "string" then return nil end + local owner, bound = Context.owner(data) + if owner then return detached(M.native(data, species, shiny)) end + if bound then return nil end -- Never revive a retired dataset view. + -- Reference palette-only records carry the two ROM middle colours, rather + -- than native names into a four-colour pack. Preserve that public input. + local rows = data.pokemon + local entry = type(rows) == "table" and rows[species] + if type(entry) ~= "table" then return nil end + local pair = shiny and entry.shiny or entry.normal + if type(pair) ~= "table" then return nil end + local first, second = color(pair[1]), color(pair[2]) + if not first or not second then return nil end + return { {255,255,255}, first, second, {0,0,0} } +end +return M diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 52c994215..fd6aee72e 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -839,6 +839,10 @@ function PaletteFX.monPal(data, species, transformed, shiny) return p.palettes.GRAYMON or (data and data.palettes and data.palettes.palettes.GRAYMON) end + if GameVersion.isGen2() then + local native = require("src.render.Gen2PokemonPalettes").native(data, species, shiny) + if native then return native end + end local def = data and data.pokemon and data.pokemon[species] if shiny and def and def.shinyPalette then local pal = p.palettes[def.shinyPalette] @@ -880,6 +884,12 @@ function PaletteFX.monPal(data, species, transformed, shiny) return nil end +-- Legacy external Gen2 consumers ask the published palette view directly. +-- Resolve the same native row as monPal and return fresh RGB values. +function PaletteFX.monColors(data, species, shiny) + return require("src.render.Gen2PokemonPalettes").colors(data, species, shiny) +end + -- palette name a species currently resolves to (for image-cache keys) function PaletteFX.monPalName(data, species, transformed, shiny) if transformed then return "GRAYMON" end diff --git a/tests/engine/gen2_palette_draw.lua b/tests/engine/gen2_palette_draw.lua new file mode 100644 index 000000000..8bc99a6e6 --- /dev/null +++ b/tests/engine/gen2_palette_draw.lua @@ -0,0 +1,105 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local P=require('src.render.PaletteFX') +local G=require('src.render.GbcPalette') +local LCD=require('src.render.GBCFX') +local V=require('src.core.GameVersion');local edition=V.get() +local Json=require('src.link.Json');local Loader=require('src.mods.Loader') +local old=love.graphics +local A={{243,241,239},{219,17,31},{19,197,83},{23,29,157}} +local B={{233,229,227},{11,61,211},{173,37,139},{3,7,13}} +local function normalized(p) + local out={};for i=1,4 do local r=p[i];out['pal'..i-1]={r[1]/255,r[2]/255,r[3]/255}end;return out +end +local active, builds, failSend=nil,0,nil +local function fakeShader(code) + builds=builds+1 + return {source=code,values={},send=function(self,name,value) + if name==failSend then failSend=nil;error('uniform upload failed')end + self.values[name]={unpack(value)} + end} +end +love.graphics={newShader=fakeShader,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G,require('src.render.Gen2PaletteDraw'),'public spelling has typed native implementation') +T.neq(G,LCD,'palette drawing must not resolve to LCD screen effect') +T.eq(type(LCD.applyOptions),'function','native LCD options retained') +T.check(G.available(),'indexed shader is available') +local standard,keyed=G.shader(),G.keyedShader() +T.neq(standard,keyed,'keyed drawing has separate alpha policy') +T.eq(builds,2,'one lazy shader per variant') +T.check(standard.source:find('pixel.a',1,true)~=nil,'source alpha retained') +T.check(standard.source:find('* tint',1,true)~=nil,'draw colour retained') +T.check(keyed.source:find('shade < 0.5 ? 0.0',1,true)~=nil,'keyed source shade zero is transparent') +for _,variant in ipairs({'with','withRaw','keyedWith'})do + active={external=true};local external=active + local wanted=variant=='keyedWith'and keyed or standard + T.check(G[variant](A,function() + T.eq(active,wanted,variant..' shader active inside callback') + T.same(active.values,normalized(A),variant..' all four RGB rows normalized') + G.withRaw(B,function()T.same(active.values,normalized(B),variant..' nested different palette active')end) + T.eq(active,wanted,variant..' nested return restores shader') + T.same(active.values,normalized(A),variant..' nested return restores uniforms') + local ok,err=pcall(function()G.withRaw(B,function()error('authored callback failure',0)end)end) + T.check(not ok and err=='authored callback failure',variant..' callback failure propagated') + T.eq(active,wanted,variant..' nested failure restores shader') + T.same(active.values,normalized(A),variant..' nested failure restores outer palette') + end),variant..' returns applied flag') + T.eq(active,external,variant..' restores foreign shader') +end +G.useRaw(A);A[2][1]=111 +G.withRaw(B,function()end) +local saved=normalized(A);saved.pal1[1]=219/255 +T.same(active.values,saved,'restoration does not borrow caller palette rows') +A[2][1]=219 +standard:send('pal1',{0,0,0});G.useRaw(A) +T.same(active.values,normalized(A),'later public call repairs externally edited uniforms') +G.useRaw(A);failSend='pal2' +local ok,err=pcall(function()G.withRaw(B,function()error('body must not run')end)end) +T.check(not ok and tostring(err):find('uniform upload failed',1,true)~=nil,'upload failure is propagated') +T.eq(active,standard,'partial upload restores previous shader') +T.same(active.values,normalized(A),'partial upload restores all previous uniforms') +local nativeMode,nativeMap=P.mode,P.shadeMap() +for _,game in ipairs({'red','gold','silver','crystal'})do + V.set(game) + for _,mode in ipairs(P.MODES)do + P.setMode(mode);P.setShadeMap({[0]=3,[1]=1,[2]=2,[3]=0}) + local resolved=P.effectiveColors(A) + T.same(G.resolve(A),resolved,game..' '..mode..' shares actual native colour/register resolution') + G.with(A,function()T.same(active.values,normalized(resolved),game..' '..mode..' actual use shares resolved colours')end) + G.withRaw(A,function()T.same(active.values,normalized(A),game..' '..mode..' raw use bypasses display/register state')end) + T.same(G.color(A,2),resolved[2],game..' '..mode..' solid colour shares same native row') + end +end +P.setShadeMap(nativeMap);P.setMode(nativeMode);V.set(edition) +for _,bad in ipairs({false,{},{{1,2,3}},{{1,2,3},{1,2,3},{1,2,3},{0/0,2,3}},{{1,2,3},{1,2,3},{1,2,3},{256,2,3}}})do + active={foreign=true};local foreign=active;local called=false + T.eq(G.withRaw(bad,function()called=true;T.eq(active,foreign,'invalid palette retains previous shader in body')end),false,'invalid palette declines colourization') + T.check(called,'invalid palette still executes draw callback') + T.eq(active,foreign,'invalid palette restores previous shader') +end +G.clear();T.eq(active,nil,'clear restores ordinary drawing') +love.graphics={} +T.eq(G.available(),false,'headless renderer declines shader availability') +local called=false;T.eq(G.with(A,function()called=true end),false,'headless with declines colourization') +T.check(called,'headless callback still executes') +love.graphics={newShader=function()error('compiler unavailable')end,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G.available(),false,'compile failure is an unavailable feature') +love.graphics.newShader=fakeShader +T.check(G.available(),'replacement compiler can recover after a failed compile') +for _,api in ipairs({1,2})do + local files={['mods/draw/manifest.json']=Json.encode({id='draw',name='Native indexed drawing consumer',version='1.0.0',api=api,content_format=api==3 and 'unified'or'native',entry='main.lua',affects_link=false}), + ['mods/draw/main.lua']=[[ + local mod=...;local Gbc=require('src.render.GbcPalette') + mod.exports.draw=function(colors,body)return Gbc.with(colors,body)end + ]]} + local r=T.sdk.loadMods({'mods/draw'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,'API'..api..' unchanged indexed consumer loads') + local ran=false + T.check(r.loader.exports.draw.draw(A,function()ran=true end),'API'..api..' loaded consumer applies palette') + T.check(ran,'API'..api..' loaded draw callback executes') + Loader.teardown();r.release() +end +love.graphics=old +T.finish('native indexed palette drawing, nested uniform and shader restoration') diff --git a/tests/engine/gen2_pokemon_palette_queries.lua b/tests/engine/gen2_pokemon_palette_queries.lua new file mode 100644 index 000000000..6ce9ae5ff --- /dev/null +++ b/tests/engine/gen2_pokemon_palette_queries.lua @@ -0,0 +1,71 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes');local Data=require('src.core.Data') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local mode=P.mode +local function palette(seed)return {{255,255,255},{seed,2,3},{4,seed+5,6},{0,0,0}}end +local function fixture() + local d=T.fixtures.fresh();local normal,shiny,custom=palette(20),palette(60),palette(100) + d.tilesets={QUERY={palMap={0},palColorsByTod={DAY={normal}}}} + d.pokemon={SPECIES_133={id='SPECIES_133',index=133,dex=133,name='SPECIES_133',palette='QA_NORMAL',shinyPalette='QA_SHINY'}, + SPECIES_149={id='SPECIES_149',index=149,dex=149,name='DRAGONITE'}, + NO_COLORS={id='NO_COLORS'}} + d.palettes={palettes={QA_NORMAL=normal,QA_SHINY=shiny,QA_CUSTOM=custom,QA_MAPPED=palette(140), + MEWMON=palette(170),GRAYMON=palette(180)},pokemon={SPECIES_149='QA_MAPPED'}} + d.constants.speciesOrder={[133]='SPECIES_133',[149]='SPECIES_149'} + Data.publishGen2Palettes(d) + return d,normal,shiny,custom +end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);local d,normal,shiny,custom=fixture();local view=d.gen2Palettes + for _,colorMode in ipairs(P.MODES)do + P.setMode(colorMode) + T.same(P.monColors(view,'SPECIES_133'),normal,edition..' public normal RGB in '..colorMode) + T.same(P.monColors(view,'SPECIES_133',true),shiny,edition..' public shiny RGB in '..colorMode) + T.same(P.monColors(d,'SPECIES_133'),normal,edition..' full native dataset accepted in '..colorMode) + T.eq(P.monPal(d,'SPECIES_133',false,false),normal,edition..' native renderer keeps source table, without copied palettes') + T.eq(P.monPal(d,'SPECIES_133',false,true),shiny,edition..' native shiny renderer shares source selection') + end + P.setMode('gbc') + T.same(P.monColors(view,'SPECIES_149'),d.palettes.palettes.QA_MAPPED,edition..' native species-to-palette map retained') + T.eq(P.monColors(view,'ABSENT'),nil,edition..' unknown species does not acquire arbitrary fallback colours') + T.eq(P.monColors(view,'NO_COLORS'),nil,edition..' existing species without native palette stays unavailable') + local copy=P.monColors(view,'SPECIES_133');copy[2][1]=0;copy[1]=nil + T.eq(normal[2][1],20,edition..' external recolouring cannot mutate native palette') + T.same(P.monColors(view,'SPECIES_133'),normal,edition..' later read is detached from previous query') + d.pokemon.SPECIES_133.palette='QA_CUSTOM' + T.same(P.monColors(view,'SPECIES_133'),custom,edition..' live authored palette replacement reaches external view') + T.eq(P.monPal(d,'SPECIES_133',false,false),custom,edition..' same replacement reaches native renderer') + d.palettes.palettes.QA_SHINY=nil + T.same(P.monColors(view,'SPECIES_133',true),custom,edition..' unavailable shiny palette falls back to same native normal row') + T.eq(P.monPal(d,'SPECIES_133',true,true),d.palettes.palettes.GRAYMON,edition..' transformed native rendering keeps original precedence') + local bytes=Json.encode(d.palettes) + P.monColors(view,'SPECIES_133');T.eq(Json.encode(d.palettes),bytes,edition..' public read does not publish another palette catalogue') + Data.publishGen2Palettes(d) + T.eq(P.monColors(view,'SPECIES_133'),nil,edition..' retired view cannot revive a previous binding') + local legacy={pokemon={LOCAL={normal={{21,22,23},{31,32,33}},shiny={{41,42,43},{51,52,53}}}}} + T.same(P.monColors(legacy,'LOCAL'),{{255,255,255},{21,22,23},{31,32,33},{0,0,0}},edition..' reference pair input preserved') + T.same(P.monColors(legacy,'LOCAL',true),{{255,255,255},{41,42,43},{51,52,53},{0,0,0}},edition..' reference shiny pair preserved') + legacy.pokemon.LOCAL.shiny=nil + T.same(P.monColors(legacy,'LOCAL',true),P.monColors(legacy,'LOCAL'),edition..' reference absent shiny falls back to normal') + legacy.pokemon.LOCAL.normal[1][2]=0/0 + T.eq(P.monColors(legacy,'LOCAL'),nil,edition..' malformed colour is not handed to external shader') + for _,api in ipairs({1,2})do + local files={['mods/colors/manifest.json']=Json.encode({id='colors',name='Native Pokemon palette query',version='1.0.0',api=api,content_format=api==3 and 'unified' or 'native',entry='main.lua',affects_link=false}), + ['mods/colors/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + mod.exports.colors=function(data,species,shiny)return Palettes.monColors(data.gen2Palettes,species,shiny)end + ]]} + local fresh=fixture();local r=T.sdk.loadMods({'mods/colors'},{data=fresh,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,edition..' API'..api..' unchanged public consumer loads') + T.same(r.loader.exports.colors.colors(fresh,'SPECIES_133'),fresh.palettes.palettes.QA_NORMAL,edition..' API'..api..' loaded public query reads actual native palette') + Loader.teardown();r.release() + end +end +for _,args in ipairs({{}, {false}, {{},false}, {{pokemon={BAD=false}},'BAD'}})do + T.eq(P.monColors(unpack(args)),nil,'missing or invalid colour query declines') +end +P.setMode(mode);V.set(version) +T.finish('native Gen2 Pokemon palette queries and shared native selection') diff --git a/tests/gpu/gen2_palette_draw.lua b/tests/gpu/gen2_palette_draw.lua new file mode 100644 index 000000000..0c84afaf9 --- /dev/null +++ b/tests/gpu/gen2_palette_draw.lua @@ -0,0 +1,77 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Call run() from an isolated LÖVE host, on each graphics backend. +local M = {} +function M.run() + require('src.core.ModCompat').install() + local G = require('src.render.GbcPalette') + local P = require('src.render.PaletteFX') + local V = require('src.core.GameVersion') + local beforeVersion, beforeMode, beforeMap = V.get(), P.mode, P.shadeMap() + V.set('crystal') + local checks = 0 + local function check(ok, message) + checks = checks + 1 + assert(ok, message) + end + local A = {{247,239,229},{213,19,43},{23,193,79},{17,31,151}} + local B = {{233,227,211},{7,59,199},{163,41,137},{3,11,29}} + local shades = love.image.newImageData(4,1,'rgba8') + for x=0,3 do local n=1-x/3;shades:setPixel(x,0,n,n,n,1-x/4) end + local image = love.graphics.newImage(shades);image:setFilter('nearest','nearest') + local canvas = love.graphics.newCanvas(4,12,{format='rgba8',dpiscale=1}) + local expected = {} + local function draw(row, palette, keyed, tint) + love.graphics.setColor(unpack(tint or {1,1,1,1})) + love.graphics.draw(image,0,row) + expected[row] = {palette=palette,keyed=keyed,tint=tint or {1,1,1,1}} + end + love.graphics.push('all') + local ok, why = pcall(function() + love.graphics.setCanvas(canvas);love.graphics.clear(0,0,0,0) + love.graphics.setBlendMode('replace','premultiplied') + check(G.with(A,function()draw(0,A,false,{0.5,0.75,1,0.8})end),'normal draw unavailable') + check(G.keyedWith(A,function()draw(1,A,true)end),'keyed draw unavailable') + G.withRaw(A,function() + draw(2,A) + G.withRaw(B,function()draw(3,B)end) + draw(4,A) + local success,err=pcall(function()G.withRaw(B,function()error('nested failure',0)end)end) + check(not success and err=='nested failure','nested callback error lost') + draw(5,A) + G.keyedWith(B,function()draw(6,B,true)end) + draw(7,A) + end) + P.setShadeMap({[0]=3,[1]=2,[2]=1,[3]=0}) + G.with(A,function()draw(8,{A[4],A[3],A[2],A[1]})end) + G.withRaw(A,function()draw(9,A)end) + local foreign=love.graphics.newShader([[ + vec4 effect(vec4 tint,Image image,vec2 uv,vec2 screen){return vec4(0.2,0.4,0.6,0.8);} + ]]) + love.graphics.setShader(foreign) + G.withRaw(B,function()draw(10,B)end) + check(love.graphics.getShader()==foreign,'foreign shader identity not restored') + love.graphics.setColor(1,1,1,1);love.graphics.draw(image,0,11) + love.graphics.setCanvas();love.graphics.setShader() + local pixels=canvas:newImageData() + for row,expect in pairs(expected)do + for x=0,3 do + local r,g,b,a=pixels:getPixel(x,row) + local rgb=expect.palette[x+1];local tint=expect.tint + local wanted={rgb[1]/255*tint[1],rgb[2]/255*tint[2],rgb[3]/255*tint[3], + expect.keyed and x==0 and 0 or (1-x/4)*tint[4]} + for c,value in ipairs({r,g,b,a})do + check(math.abs(value-wanted[c])<=2/255,'pixel mismatch row '..row..' shade '..x..' channel '..c) + end + end + end + for x=0,3 do + local pixel={pixels:getPixel(x,11)} + for c,n in ipairs({0.2,0.4,0.6,0.8})do check(math.abs(pixel[c]-n)<=2/255,'foreign shader pixel not restored')end + end + end) + love.graphics.pop() + P.setShadeMap(beforeMap);P.setMode(beforeMode);V.set(beforeVersion) + if not ok then error(why,0)end + return {passed=true,checks=checks,scope='GPU pixel equality for shade palette, tint, alpha, keying, nested success/error and foreign shader restoration'},canvas +end +return M From 287f6fabf8ed1fb6e29928b443767db0e7b45217 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:01:21 +0100 Subject: [PATCH 044/187] Expose native palette mode to preserve already-coloured evolution pictures --- docs/native-gen2-pokemon-palettes.md | 3 +++ src/render/Gen2PaletteDraw.lua | 11 +++++++++++ tests/engine/gen2_palette_draw.lua | 4 ++++ 3 files changed, 18 insertions(+) diff --git a/docs/native-gen2-pokemon-palettes.md b/docs/native-gen2-pokemon-palettes.md index 98e69c1f8..d6537cc18 100644 --- a/docs/native-gen2-pokemon-palettes.md +++ b/docs/native-gen2-pokemon-palettes.md @@ -17,6 +17,9 @@ palette while preserving texture alpha and drawing tint. `use`, `with`, `available`, `resolve`, `color` and `clear` are provided. Keyed drawing makes source shade zero transparent. Normal drawing resolves native `PaletteFX` display/register state; raw drawing bypasses that resolution. +The public `mode` read follows that native resolution, so external evolution +presenters can preserve already coloured art instead of passing it through a +grayscale-index shader. It does not create another display option. Scoped drawing restores both the previous shader object and managed palette uniforms, including nested calls, callback failures and partial upload errors. diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua index b0c271a45..8d3b93f2e 100644 --- a/src/render/Gen2PaletteDraw.lua +++ b/src/render/Gen2PaletteDraw.lua @@ -2,7 +2,18 @@ -- Indexed image drawing, separate from the LCD post-process in GBCFX. -- Palette resolution uses the native display/register state in PaletteFX. local Palettes = require("src.render.PaletteFX") +local Version = require("src.core.GameVersion") local M = {} +-- Consumers use this view to leave already-coloured pictures untouched. +-- Gen2 currently resolves native ROM colours regardless of the Gen1 mode. +-- Do not invent a second mutable display setting for the external renderer. +setmetatable(M, {__index = function(_, key) + if key == "mode" then + if Version.isGen2() then return "gbc" end + if Palettes.mode == "og" or Palettes.mode == "og_inv" then return "dmg" end + return Palettes.mode == "classic" and "classic" or "gbc" + end +end}) local compiler, shaders, failed = nil, {}, {} local sent = setmetatable({}, {__mode = "k"}) local names = {"pal0", "pal1", "pal2", "pal3"} diff --git a/tests/engine/gen2_palette_draw.lua b/tests/engine/gen2_palette_draw.lua index 8bc99a6e6..76de1532e 100644 --- a/tests/engine/gen2_palette_draw.lua +++ b/tests/engine/gen2_palette_draw.lua @@ -66,6 +66,10 @@ for _,game in ipairs({'red','gold','silver','crystal'})do for _,mode in ipairs(P.MODES)do P.setMode(mode);P.setShadeMap({[0]=3,[1]=1,[2]=2,[3]=0}) local resolved=P.effectiveColors(A) + local display=game~='red'and'gbc'or(mode=='classic'and'classic'or((mode=='og'or mode=='og_inv')and'dmg'or'gbc')) + T.eq(G.mode,display,game..' '..mode..' public mode follows actual native resolution') + local trueColor=true + T.eq(not(trueColor and G.mode=='gbc'),display~='gbc',game..' '..mode..' unchanged evolution colour gate preserves true-colour art') T.same(G.resolve(A),resolved,game..' '..mode..' shares actual native colour/register resolution') G.with(A,function()T.same(active.values,normalized(resolved),game..' '..mode..' actual use shares resolved colours')end) G.withRaw(A,function()T.same(active.values,normalized(A),game..' '..mode..' raw use bypasses display/register state')end) From 6c9be52eedb07ed80e2265cc9b32780649ff5415 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:39:00 +0100 Subject: [PATCH 045/187] Share native GSC Unown form registration and imported data with external consumers --- docs/native-gen2-unown.md | 42 +++++++++++ src/battle/BattleState.lua | 5 +- src/compat/gen2/Unown.lua | 19 +++++ src/core/ModCompat.lua | 1 + src/pokemon/Gen2Unown.lua | 125 ++++++++++++++++++++++++++++++ src/pokemon/Sprites.lua | 10 +-- src/script/Commands.lua | 2 + src/script/Gen2Commands.lua | 6 ++ src/ui/PokedexUnownMode.lua | 2 + tests/engine/gen2_unown.lua | 146 ++++++++++++++++++++++++++++++++++++ 10 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 docs/native-gen2-unown.md create mode 100644 src/compat/gen2/Unown.lua create mode 100644 src/pokemon/Gen2Unown.lua create mode 100644 tests/engine/gen2_unown.lua diff --git a/docs/native-gen2-unown.md b/docs/native-gen2-unown.md new file mode 100644 index 000000000..2706cc7c2 --- /dev/null +++ b/docs/native-gen2-unown.md @@ -0,0 +1,42 @@ +# Native GSC Unown state + +`src.pokemon.Gen2Unown` shares native Gold/Silver/Crystal form identity and +catch-order state between sprite selection, battle captures, script gifts, +VAR_UNOWNCOUNT, the native Unown dex and public consumers. Stored reference +letter overrides and DV-derived forms use the same sprite/counter decision. +The byte calculation follows the existing native GetUnownLetter arithmetic. + +`src.core.gen2.Unown` is an explicit adapter to this service. `word(letter)` +reads the active native `Game.data.unown_dex.words`; an explicit native dataset +may be supplied as a second argument. No retail word catalogue or picture +paths are embedded in the adapter. `forms(pokemon)` maps A–Z names to the +actual numeric native form records; `formSprite` resolves the same records. +Reads follow the current dataset instead of retaining an old edition's data. + +The native save remains `save.pokedex.unownDex` and `unownForms`. Public `dex` +reads return a detached list. Known ordered forms keep their order; missing +valid entries from an older set-only save are appended alphabetically. Reads +do not migrate saves. A new form writes the normalized ordered list and set; +no separate `save.unownDex` is created. New party and PC captures and script +gifts register only after successful transfer. Eggs and refused transfers do +not register a form. + +`unown.unlocked` reports a newly recorded form once, after native state is +committed. Payload fields are `letter`, `name`, imported `word` and `count`. +Native API1/2 consumers use this same producer. Repeated catches, +reentrant duplicate writes and another species do not fire the event. + +## Verification and limits + +ROM-free coverage includes all 65,536 packed DV byte combinations, native +sprite and stored-letter selection, actual party/PC capture and gift paths, +old save reads, refused full storage, native counter/menu agreement, imported +word replacement, actual Loader consumers and event rollback boundaries. +Real-ROM checks are separate private receipts; ROM data is never committed. + +This does not establish the complete reference Unown API: unlocked-set/wild +DV selection, puzzle/encounter gates, direct mutation of reference-shaped view +tables and the remaining legacy Pokédex screen protocol need further work. +Polished Crystal's 28 forms and other editions retain their previous native +catch paths. Full Unown story/GPU/hardware and complete VASC/Gen1 parity are +not claimed. VASC files and protected Gen1/player instances are unchanged. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 297e84c74..38b8b2ac5 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -9940,7 +9940,8 @@ function BattleState:storeCaughtMon() -- Ruins of Alph researcher and the dex's UNOWN mode gate on local form = require("src.pokemon.Sprites").formIndex( game.data.pokemon[species], self.enemy.mon) - if dex and type(form)=='number' then + local nativeUnown = require("src.pokemon.Gen2Unown") + if not nativeUnown.supported() and dex and type(form)=='number' then dex.unownForms = dex.unownForms or {} if not dex.unownForms[form] then dex.unownForms[form] = true @@ -9984,12 +9985,14 @@ function BattleState:storeCaughtMon() self.enemy.mon.ball = self.lastBall end if Party.add(game.save.party, self.enemy.mon) then + if nativeUnown.supported() then nativeUnown.registerCatch(game.save, self.enemy.mon, game.data) end require('src.pokemon.Gen4PoketchState').remember(game, self.enemy.mon) askCaughtNickname() else destination = "box" local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) if boxNum then + if nativeUnown.supported() then nativeUnown.registerCatch(game.save, self.enemy.mon, game.data) end require('src.pokemon.Gen4PoketchState').remember(game, self.enemy.mon) askCaughtNickname() -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL diff --git a/src/compat/gen2/Unown.lua b/src/compat/gen2/Unown.lua new file mode 100644 index 000000000..c69e3e0af --- /dev/null +++ b/src/compat/gen2/Unown.lua @@ -0,0 +1,19 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Reference spellings over the native imported records and native save. +local Native = require("src.pokemon.Gen2Unown") +local M = {NUM_UNOWN=Native.NUM_UNOWN, SPECIES="UNOWN", ALPHABET=Native.ALPHABET, + name=Native.name, index=Native.index, letterFromDVs=Native.letterFromDVs, + dvsForLetter=Native.dvsForLetter, monLetter=Native.monLetter, + word=Native.word, formSprite=Native.formSprite, + dex=Native.letters, caught=Native.caught, count=Native.count, + updateDex=Native.updateDex, registerCatch=Native.registerCatch} +-- Reference consumers index forms by A..Z; native storage indexes by number. +-- The public name map is derived on demand; picture records remain native. +function M.forms(pokemon) + local source = Native.forms(pokemon) + if not source then return nil end + local out = {} + for i = 1, 26 do if source[i] then out[Native.name(i)] = source[i] end end + return out +end +return M diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..0ac1b1c54 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -67,6 +67,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + ["src.core.gen2.Unown"] = "src.compat.gen2.Unown", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/pokemon/Gen2Unown.lua b/src/pokemon/Gen2Unown.lua new file mode 100644 index 000000000..48d6d55cc --- /dev/null +++ b/src/pokemon/Gen2Unown.lua @@ -0,0 +1,125 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared native GSC form identity and catch-order state. Words and pictures +-- remain in the imported dataset, never in a second cartridge catalogue. +local M = {NUM_UNOWN = 26, ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"} +function M.supported() + local version = require("src.core.GameVersion").get() + return version == "gold" or version == "silver" or version == "crystal" +end +function M.index(letter) + if type(letter) == "string" and #letter == 1 then + return M.ALPHABET:find(letter:upper(), 1, true) + end + if type(letter) == "number" and letter % 1 == 0 and letter >= 1 and letter <= 26 then + return letter + end +end +function M.name(letter) + local index = M.index(letter) + return index and M.ALPHABET:sub(index, index) or nil +end +local function middle(dv) + dv = dv or 0 + if type(dv) ~= "number" or dv ~= dv or dv < 0 or dv > 15 then return nil end + return math.floor(dv / 2) % 4 +end +function M.letterFromDVs(dvs) + if type(dvs) ~= "table" then return nil end + local a,b,c,d = middle(dvs.attack), middle(dvs.defense), middle(dvs.speed), middle(dvs.special) + if not (a and b and c and d) then return nil end + return math.floor((a*64+b*16+c*4+d) / 10) + 1 +end +function M.dvsForLetter(letter) + local index = M.index(letter) + if not index then return nil end + local packed = (index - 1) * 10 + return {attack = math.floor(packed / 64) % 4 * 2, + defense = math.floor(packed / 16) % 4 * 2, + speed = math.floor(packed / 4) % 4 * 2, special = packed % 4 * 2} +end +function M.liveData(data) + if type(data) == "table" then return data end + local game = package.loaded["src.core.Game"] + return type(game) == "table" and type(game.data) == "table" and game.data or nil +end +function M.definition(data) + data = M.liveData(data) + if not (data and type(data.pokemon) == "table") then return nil end + local known = data.pokemon.UNOWN + if type(known) == "table" then return known end + for _, def in pairs(data.pokemon) do + if type(def) == "table" and (def.translationSourceName or def.name) == "UNOWN" then + return def + end + end +end +function M.word(letter, data) + local index = M.index(letter) + data = M.liveData(data) + local record = data and data.unown_dex + local words = type(record) == "table" and record.words + local word = index and type(words) == "table" and words[index] + return type(word) == "string" and word or nil +end +function M.forms(pokemon) + local def = M.definition({pokemon = pokemon}) + return def and def.forms or nil +end +function M.formSprite(pokemon, letter, back) + local index, def = M.index(letter), M.definition({pokemon = pokemon}) + if not (index and def) then return nil end + local form = def.forms and def.forms[index] + if back then return form and form.spriteBack or def.spriteBack end + return form and form.spriteFront or def.spriteFront +end +function M.monLetter(mon, data) + if type(mon) ~= "table" or mon.isEgg then return nil end + data = M.liveData(data) + local def = data and data.pokemon and data.pokemon[mon.species] + if def then + if def ~= M.definition(data) then return nil end + elseif mon.species ~= "UNOWN" then return nil end + return M.index(mon.unownLetter) or M.letterFromDVs(mon.dvs) +end +function M.letters(save) + local dex = type(save) == "table" and save.pokedex + if type(dex) ~= "table" then return {} end + local out, seen = {}, {} + local function add(letter) + local index = M.index(letter) + if index and not seen[index] then seen[index] = true; out[#out + 1] = index end + end + for _, letter in ipairs(type(dex.unownDex) == "table" and dex.unownDex or {}) do add(letter) end + local forms = type(dex.unownForms) == "table" and dex.unownForms or {} + for i = 1, 26 do if forms[i] then add(i) end end + return out +end +function M.count(save) return #M.letters(save) end +function M.caught(save, letter) + local index = M.index(letter) + if not index then return false end + for _, present in ipairs(M.letters(save)) do if present == index then return true end end + return false +end +function M.updateDex(save, letter, data) + local index = M.index(letter) + if not (M.supported() and type(save) == "table" and index) or M.caught(save, index) then return false end + if save.pokedex ~= nil and type(save.pokedex) ~= "table" then return false end + if save.pokedex and save.pokedex.unownForms ~= nil and type(save.pokedex.unownForms) ~= "table" then return false end + local list = M.letters(save) + list[#list + 1] = index + save.pokedex = save.pokedex or {} + local dex = save.pokedex + dex.unownDex, dex.unownForms = list, dex.unownForms or {} + for _, value in ipairs(list) do dex.unownForms[value] = true end + local Runtime = require("src.mods.Runtime") + if Runtime.wants("unown.unlocked") then + Runtime.emit("unown.unlocked", {letter=index, name=M.name(index), word=M.word(index, data), count=#list}) + end + return true +end +function M.registerCatch(save, mon, data) + local letter = M.monLetter(mon, data) + return letter and M.updateDex(save, letter, data) or false +end +return M diff --git a/src/pokemon/Sprites.lua b/src/pokemon/Sprites.lua index 245e67151..9c52bea60 100644 --- a/src/pokemon/Sprites.lua +++ b/src/pokemon/Sprites.lua @@ -14,9 +14,6 @@ local Sprites = {} local function samePath(path) return path end --- (dv & 6) >> 1 -- the middle two bits GetUnownLetter (20:$5749) keeps -local function mid2(dv) return math.floor((dv or 0) / 2) % 4 end - -- GetUnownLetter packs the middle two bits of the atk/def/spd/spc DVs into -- one byte and divides by 10, giving letter 1..26. A species record only -- carries `forms` when the ROM has per-form pics (UNOWN). @@ -28,10 +25,9 @@ function Sprites.formIndex(def, mon) return mon.form end local dvs = mon.dvs - if type(dvs) ~= "table" then return nil end - local packed = mid2(dvs.attack) * 64 + mid2(dvs.defense) * 16 - + mid2(dvs.speed) * 4 + mid2(dvs.special) - local index = math.floor(packed / 10) + 1 + local Unown = require("src.pokemon.Gen2Unown") + local index = (Unown.supported() and Unown.index(mon.unownLetter)) or Unown.letterFromDVs(dvs) + if not index then return nil end if not forms[index] then return nil end return index end diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 53cef91fa..adfc5e7ce 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -1333,6 +1333,8 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts) dex.seen[species] = true dex.owned[species] = true end + local Unown = require("src.pokemon.Gen2Unown") + if Unown.supported() then Unown.registerCatch(ctx.save, mon, ctx.game.data) end require('src.pokemon.Gen4Forms').record(ctx.game,species,mon) ctx.lastCheck = true ctx.addedToParty = addedToParty diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..53eb79977 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2666,6 +2666,12 @@ function Commands.g2_readvar(ctx, var) -- sampled by `special SampleKenjiBreakCountdown` ctx.g2Var = ctx.save.g2KenjiBreak or 0 elseif var == 14 then + local Unown = require("src.pokemon.Gen2Unown") + if Unown.supported() then + ctx.g2Var = Unown.count(ctx.save) + ctx.lastCheck = ctx.g2Var ~= 0 + return + end -- UnownCaught (engine/events/unown_walls.asm): how many distinct UNOWN -- letters are in the dex. The port has no per-letter model, so a caught -- UNOWN counts once -- enough for the Ruins researcher's `ifgreater 2` diff --git a/src/ui/PokedexUnownMode.lua b/src/ui/PokedexUnownMode.lua index d5907e743..0906e2f59 100644 --- a/src/ui/PokedexUnownMode.lua +++ b/src/ui/PokedexUnownMode.lua @@ -54,6 +54,8 @@ end -- only have the unownForms set, so fall back to alphabetical for those -- rather than showing an empty page. local function caughtLetters(save) + local Unown = require("src.pokemon.Gen2Unown") + if Unown.supported() then return Unown.letters(save) end local dex = save and save.pokedex if not dex then return {} end local ordered = dex.unownDex diff --git a/tests/engine/gen2_unown.lua b/tests/engine/gen2_unown.lua new file mode 100644 index 000000000..13a7e031e --- /dev/null +++ b/tests/engine/gen2_unown.lua @@ -0,0 +1,146 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local previous=V.get() +require('src.core.ModCompat').install() +require('src.script.Gen2Commands') +local U=require('src.core.gen2.Unown');local N=require('src.pokemon.Gen2Unown') +local Json=require('src.link.Json');local Loader=require('src.mods.Loader') +local Game=require('src.core.Game');local originalData=Game.data +local function fixture() + local d=T.fixtures.fresh();local def=require('src.mods.Merge').deepCopy(d.pokemon.FIXMON_A) + def.id='SPECIES_201';def.index=201;def.dex=201;def.name='UNOWN';def.forms={} + for i=1,26 do def.forms[i]={spriteFront='native-front-'..i,spriteBack='native-back-'..i}end + d.pokemon.SPECIES_201=def;d.constants.speciesOrder={[201]='SPECIES_201'} + + d.unown_dex={words={}};for i=1,26 do d.unown_dex.words[i]='AUTHORED-'..i end + return d +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local d=fixture();Game.data=d + T.neq(U,N,version..' reference spelling is an explicit native adapter') + for i=1,26 do + local dvs=U.dvsForLetter(i);local mon={species='SPECIES_201',dvs=dvs} + T.eq(U.index(U.name(i)),i,version..' complete letter identity roundtrip') + T.eq(U.letterFromDVs(dvs),i,version..' inverse has correct native DV letter') + T.eq(require('src.pokemon.Sprites').formIndex(d.pokemon.SPECIES_201,mon),i,version..' native sprite form shares DV selection') + T.eq(U.monLetter(mon),i,version..' public mon identity reads current native dataset') + T.eq(U.word(i),'AUTHORED-'..i,version..' words are imported data, not a retail catalogue') + T.eq(U.formSprite(d.pokemon,i),d.pokemon.SPECIES_201.forms[i].spriteFront,version..' native form art path returned') + T.eq(U.formSprite(d.pokemon,i,true),d.pokemon.SPECIES_201.forms[i].spriteBack,version..' native back art path returned') + end + T.eq(U.forms(d.pokemon).A,d.pokemon.SPECIES_201.forms[1],version..' public named form resolves actual native first row') + T.eq(U.forms(d.pokemon).Z,d.pokemon.SPECIES_201.forms[26],version..' public named form resolves actual native last row') + local save={pokedex={unownForms={[1]=true,[3]=true,[5]=false,[99]=true}}};local before=Json.encode(save) + T.same(U.dex(save),{1,3},version..' older set-only save remains readable') + T.eq(U.count(save),2,version..' invalid/false form flags are not real letters') + T.eq(Json.encode(save),before,version..' reading an older save does not migrate it') + local detached=U.dex(save);detached[1]=26;T.same(U.dex(save),{1,3},version..' public list reads are detached') + T.check(U.registerCatch(save,{species='SPECIES_201',dvs=U.dvsForLetter(4)}),version..' newly received native mon records its form') + T.same(save.pokedex.unownDex,{1,3,4},version..' previous set-only letters retained before new caught form') + T.eq(save.unownDex,nil,version..' no parallel reference save list is created') + T.eq(U.registerCatch(save,{species='SPECIES_201',dvs=U.dvsForLetter(4)}),false,version..' second catch of same form is silent') + save.pokedex.unownDex={4,4,0,27};save.pokedex.unownForms[2]=true + T.same(U.dex(save),{4,1,2,3},version..' partial or malformed old ordered data retains known order and missing old forms') + local ctx={save=save};require('src.script.Commands').g2_readvar(ctx,14) + T.eq(ctx.g2Var,U.count(save),version..' native VAR_UNOWNCOUNT shares public count') + local screen=require('src.ui.PokedexUnownMode').new({data=d,save=save}) + T.same(screen.letters,U.dex(save),version..' native dex page shares public caught order') + local stored={species='SPECIES_201',dvs=U.dvsForLetter(2),unownLetter=3} + T.eq(U.monLetter(stored),3,version..' authored stored letter has reference precedence') + T.eq(require('src.pokemon.Sprites').formIndex(d.pokemon.SPECIES_201,stored),3,version..' stored letter also reaches native rendering') + local oldMon={species='SPECIES_201',unownLetter=7} + T.eq(U.monLetter(oldMon),7,version..' older stored-form record remains readable without DVs') + T.eq(require('src.pokemon.Sprites').formIndex(d.pokemon.SPECIES_201,oldMon),7,version..' native art agrees for an older stored-form record') + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/forms/manifest.json']=Json.encode({id='forms',name='Native form observer',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/forms/main.lua']=[[ + local mod=...;local Unown=require('src.core.gen2.Unown') + mod.exports.events={};mod.exports.read=Unown.dex + mod.events:on("unown.unlocked",function(e) + mod.exports.events[#mod.exports.events+1]={letter=e.letter,name=e.name,word=e.word,count=e.count} + assert(Unown.caught(mod.exports.save,e.letter)) + assert(not Unown.updateDex(mod.exports.save,e.letter)) + e.letter=99;return false + end) + ]]} + local live=fixture();Game.data=live + local r=T.sdk.loadMods({'mods/forms'},{data=live,fs=T.sdk.memfs(files)}) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' actual Loader admits native module and public event') + local out=r.loader.exports.forms;out.save={} + T.check(N.registerCatch(out.save,{species='SPECIES_201',dvs=U.dvsForLetter(9)},live),label..' real native update reaches loaded consumer') + T.same(out.events,{{letter=9,name='I',word='AUTHORED-9',count=1}},label..' exactly one completed form notification') + T.same(out.read(out.save),{9},label..' listener mutation/return does not change the native form') + T.eq(N.registerCatch(out.save,{species='FIXMON_A',dvs=U.dvsForLetter(10)},d),false,label..' another species does not register an Unown form') + T.eq(#out.events,1,label..' non-form catch remains silent') + Loader.teardown();r.release() + end + Game.data=d + -- Native transfers run for real; only deferred UI/audio are omitted. + local Battle=require('src.battle.BattleState');local Boxes=require('src.pokemon.Boxes');Boxes.load(nil) + local function gameFor(fullParty,fullBoxes) + local save={player={name='QA',id=1},party={},pokedex={owned={},seen={}},flags={}} + if fullParty then for i=1,6 do save.party[i]={species='FIXMON_A'}end end + if fullBoxes then + local boxes=Boxes.ensure(save) + for _,box in ipairs(boxes)do for i=1,Boxes.capacity()do box[i]={species='FIXMON_A'}end end + end + return {save=save,data=d,stack={push=function()end}} + end + local function capture(game,letter) + local mon={species='SPECIES_201',level=5,dvs=U.dvsForLetter(letter)} + local battle=setmetatable({game=game,enemy={mon=mon,name='FORM'}, + restoreMimicked=function()end,sayNext=function()end,uiNext=function()end},{__index=Battle}) + battle:storeCaughtMon();return mon + end + local g=gameFor(false,false);local caught=capture(g,6) + T.eq(g.save.party[1],caught,version..' real battle capture enters party') + T.same(U.dex(g.save),{6},version..' party capture registers native form') + g=gameFor(true,false);caught=capture(g,7) + T.eq(g.save.boxes[1][1],caught,version..' real full-party capture enters native PC box') + T.same(U.dex(g.save),{7},version..' box capture registers native form') + g=gameFor(true,true);capture(g,8) + T.same(U.dex(g.save),{},version..' refused full-storage capture cannot publish a form') + local Commands=require('src.script.Commands') + for _,fullParty in ipairs({false,true})do + g=gameFor(fullParty,false) + local ctx={game=g,save=g.save} + Commands.give_pokemon(ctx,'SPECIES_201',5,true) + local received=fullParty and g.save.boxes[1][1]or g.save.party[1] + T.check(received~=nil,version..' actual gift transfer completes') + T.same(U.dex(g.save),{U.monLetter(received,d)},version..' actual gift records received native form') + end + g=gameFor(false,false) + Commands.give_pokemon({game=g,save=g.save},'SPECIES_201',5,true,{egg=true}) + T.same(U.dex(g.save),{},version..' actual egg receipt cannot register a caught form') +end +-- Independently pack the actual two DV bytes and mask hardware bit positions. +local bit=require('bit') +for first=0,255 do for second=0,255 do + local packed=bit.lshift(bit.band(first,0x60),1)+bit.lshift(bit.band(first,0x06),3) + +bit.rshift(bit.band(second,0x60),3)+bit.rshift(bit.band(second,0x06),1) + local dvs={attack=bit.rshift(first,4),defense=bit.band(first,15),speed=bit.rshift(second,4),special=bit.band(second,15)} + T.eq(U.letterFromDVs(dvs),math.floor(packed/10)+1,'all cartridge DV byte combinations preserve native form') +end end +for _,invalid in ipairs({false,0,27,-1,1.5,'','AA',0/0})do + T.eq(U.index(invalid),nil,'malformed letter has no identity') + T.eq(U.name(invalid),nil,'malformed letter has no label') + T.eq(U.updateDex({},invalid),false,'malformed form cannot enter native save') +end +T.eq(U.letterFromDVs(nil),nil,'missing DVs are not letter A') +T.eq(U.letterFromDVs({attack=math.huge}),nil,'invalid DVs cannot reach renderer') +T.eq(U.registerCatch({}, {species='UNOWN',isEgg=true,dvs=U.dvsForLetter(1)}),false,'egg has no caught form') +for _,broken in ipairs({{pokedex=false},{pokedex='bad'},{pokedex={unownForms='bad'}}})do + local bytes=Json.encode(broken);T.eq(U.updateDex(broken,1),false,'malformed native state refuses a write') + T.eq(Json.encode(broken),bytes,'refused malformed-state write is atomic') +end +T.eq(U.word(1,{unown_dex=false}),nil,'malformed imported word record is unavailable') +Game.data={pokemon={}};T.eq(U.word(1),nil,'data reload does not revive old imported words') +for _,other in ipairs({'prism','polishedcrystal','red','firered','heartgold'})do + V.set(other) + T.eq(U.updateDex({},1),false,other..' existing form model remains unchanged') +end +V.set('red');local save={};T.eq(U.updateDex(save,1),false,'native GSC save writes are generation gated') +T.same(save,{},'unsupported game is not mutated') +Game.data=originalData;V.set(previous) +T.finish('native GSC Unown identity, imported words, ordered dex and public observation') From f4ff1e2c9a1f7e823b2e3b4245d7344c4c1862d2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:49:18 +0100 Subject: [PATCH 046/187] Use the native egg predicate for Unown form registration --- src/pokemon/Gen2Unown.lua | 2 +- tests/engine/gen2_unown.lua | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pokemon/Gen2Unown.lua b/src/pokemon/Gen2Unown.lua index 48d6d55cc..a1b37adf6 100644 --- a/src/pokemon/Gen2Unown.lua +++ b/src/pokemon/Gen2Unown.lua @@ -73,7 +73,7 @@ function M.formSprite(pokemon, letter, back) return form and form.spriteFront or def.spriteFront end function M.monLetter(mon, data) - if type(mon) ~= "table" or mon.isEgg then return nil end + if type(mon) ~= "table" or require("src.pokemon.Party").isEgg(mon) then return nil end data = M.liveData(data) local def = data and data.pokemon and data.pokemon[mon.species] if def then diff --git a/tests/engine/gen2_unown.lua b/tests/engine/gen2_unown.lua index 13a7e031e..336e97dd2 100644 --- a/tests/engine/gen2_unown.lua +++ b/tests/engine/gen2_unown.lua @@ -130,6 +130,7 @@ end T.eq(U.letterFromDVs(nil),nil,'missing DVs are not letter A') T.eq(U.letterFromDVs({attack=math.huge}),nil,'invalid DVs cannot reach renderer') T.eq(U.registerCatch({}, {species='UNOWN',isEgg=true,dvs=U.dvsForLetter(1)}),false,'egg has no caught form') +T.eq(U.registerCatch({}, {species='UNOWN',eggSteps=3,dvs=U.dvsForLetter(1)}),false,'native egg counter also prevents registration when an old flag is missing') for _,broken in ipairs({{pokedex=false},{pokedex='bad'},{pokedex={unownForms='bad'}}})do local bytes=Json.encode(broken);T.eq(U.updateDex(broken,1),false,'malformed native state refuses a write') T.eq(Json.encode(broken),bytes,'refused malformed-state write is atomic') From 529fa592cdce65c1fbce7984bd21894169703961 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:26:36 +0100 Subject: [PATCH 047/187] Keep native Gen2 daily services on the saved RTC midnight --- docs/native-gen2-clock.md | 42 ++++++--- src/core/Gen2Clock.lua | 77 +++++++++++++---- src/script/Gen2Daily.lua | 17 ++-- tests/engine/gen2_clock.lua | 15 ++-- tests/engine/gen2_clock_lifecycle.lua | 119 ++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 41 deletions(-) create mode 100644 tests/engine/gen2_clock_lifecycle.lua diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md index 0bf1f6f38..5fc23558a 100644 --- a/docs/native-gen2-clock.md +++ b/docs/native-gen2-clock.md @@ -7,12 +7,31 @@ and the native happiness evolution gate. The existing `src.core.gen2.Clock` name resolves to this native module. `world.hour()`, `world.minute()` and `world.weekday()` read that same save. -`setTime(save, hour, minute)` stores `rtc.startMinute`, an offset from the -host RTC, rather than a frozen hour. `setWeekday` stores `rtc.startDay` and -keeps the older native `g2DayOffset` field synchronized. Old saves without -an RTC base retain their host-hour and existing weekday-offset behavior. -Read calls never create clock data. Invalid/non-finite writes are rejected. -The normal native save codec preserves the offsets; play time is separate. +`setTime(save, hour, minute)` anchors the chosen minute, current game +weekday and calendar date to elapsed RTC seconds. Hours, weekdays and daily +calendar identity cross the same in-game midnight, including after a saved +session has been closed for days. Automatic host DST and time-zone changes do +not add another hour to a clock the player already set. Explicit system clock +adjustments still affect the host RTC; this is not a monotonic stopwatch. + +The legacy `rtc.startMinute`, `rtc.startDay` and `g2DayOffset` projections are +retained for older readers. New writes also store `anchorTime`, `anchorMinute`, +`anchorDay` and `anchorDate` in the same `save.rtc` record. Consumers should use +the native setters/readers instead of mutating these projections directly. +`setWeekday` edits the current game weekday without changing elapsed calendar +age. Editing time reanchors the clock without resetting its current date. + +Old saves without the elapsed anchor retain their original host-hour and +weekday-offset semantics, including their old midnight relationship, until +the clock is explicitly set. Read calls never create or migrate clock data. +Invalid/non-finite writes are rejected. The normal native save codec retains +both formats; elapsed play time is separate. + +`calendarDate(save, now)` and `dateKey(save, now)` give native daily and weekly +services the calendar belonging to that RTC. Daily flags/fruit use and the +lottery now expire on game midnight and the selected game Friday instead of +an unrelated host-calendar boundary. The optional timestamp on clock reads +uses the same snapshot for minute, weekday and calendar queries. World palettes, object-load period bits and `VAR_HOUR` share the saved hour; the normal object-load freeze still applies. An explicit palette-query hour @@ -32,7 +51,9 @@ palette override. This keeps explicit native/external hour queries aligned. `clock.day_changed` is produced by actual weekday reads, including the live world clock poll. It reports `day`, `previous` and `reason` (`rollover` or `set`). A save's first observation is silent. Observations are per save, -reentrant-safe, subscription-aware and retired on mod/edition reload. +reentrant-safe, subscription-aware and retired on mod/edition reload. An +observed offline whole week still produces one change, even when the weekday +number is the same; its calendar date is different. Native API 1/2 use the existing event dispatch boundary. The native GSC producer remains generation-scoped. @@ -41,9 +62,10 @@ are exercised directly in synthetic engine tests, including minute wrap, weekly rollover, save roundtrip, RTC-versus-play-time display, the native callback path and actual mod loading. Graphics IO alone is replaced for the clock card. These checks do not establish a complete original-story clock-setting -sequence, DST, battery-backed SRAM RTC conversion, all daily scheduling or -fresh GPU acceptance for this change. Existing daily date keys still use -host calendar dates; lottery weekday selection now shares the saved weekday. +sequence, manual DST-setting/check dialogs, battery-backed SRAM RTC +conversion, all daily services or fresh GPU acceptance for this change. +Automatic host DST isolation is covered; the original story clock-setting +screen and remaining manual DST lifecycle still need native implementation. The public contract is compared with the read-only Gen1Recomp reference; the implementation is native fork code. No VASC implementation, private diff --git a/src/core/Gen2Clock.lua b/src/core/Gen2Clock.lua index fc02c2953..dc7d75d17 100644 --- a/src/core/Gen2Clock.lua +++ b/src/core/Gen2Clock.lua @@ -12,18 +12,31 @@ end local function rtc(save) return type(save) == "table" and type(save.rtc) == "table" and save.rtc or nil end -function Clock.hostMinutes() - return ((integer(os.date("%H")) or 0) * 60 + (integer(os.date("%M")) or 0)) % 1440 +function Clock.hostMinutes(now) + return ((integer(os.date("%H", now)) or 0) * 60 + (integer(os.date("%M", now)) or 0)) % 1440 end function Clock.hostWeekday(now) return (integer(os.date("%w", now)) or 0) % 7 end -function Clock.minutes(save) +-- Newly set clocks advance by elapsed RTC seconds, including while closed. +-- The old host-local offsets remain readable without migrating a save. Host +-- DST/time-zone changes must not silently change an explicitly set game clock. +local function anchored(state, now) + if not state then return nil end + local at, minute, day = integer(state.anchorTime), integer(state.anchorMinute), integer(state.anchorDay) + if not at or not minute or minute < 0 or minute >= 1440 or not day or day < 0 or day >= 7 then return nil end + now = integer(now or os.time()) + if not now then return nil end + return minute + math.floor((now - at) / 60), day +end +function Clock.minutes(save, now) local state = rtc(save) - return (Clock.hostMinutes() + (state and integer(state.startMinute) or 0)) % 1440 + local total = anchored(state, now) + if total then return total % 1440 end + return (Clock.hostMinutes(now) + (state and integer(state.startMinute) or 0)) % 1440 end -function Clock.hour(save) return math.floor(Clock.minutes(save) / 60) end -function Clock.minute(save) return Clock.minutes(save) % 60 end +function Clock.hour(save, now) return math.floor(Clock.minutes(save, now) / 60) end +function Clock.minute(save, now) return Clock.minutes(save, now) % 60 end function Clock.isSet(save) local state = rtc(save) return state ~= nil and integer(state.startMinute) ~= nil @@ -32,42 +45,76 @@ function Clock.setTime(save, hour, minute) if type(save) ~= "table" then return false end hour, minute = integer(hour or 0), integer(minute or 0) if not hour or not minute then return false end + local now = os.time() + local day, date = Clock.weekday(save, now), Clock.dateKey(save, now) + local wanted = (hour % 24) * 60 + minute % 60 save.rtc = rtc(save) or {} - save.rtc.startMinute = ((hour % 24) * 60 + minute % 60 - Clock.hostMinutes()) % 1440 + save.rtc.startMinute = (wanted - Clock.hostMinutes(now)) % 1440 + save.rtc.startDay = (day - Clock.hostWeekday(now)) % 7 + save.g2DayOffset = save.rtc.startDay + save.rtc.anchorTime, save.rtc.anchorMinute = now, wanted + save.rtc.anchorDay, save.rtc.anchorDate = day, date return true end -local function note(save, day, reason) +local function note(save, day, reason, now) if type(save) ~= "table" then return day end local Runtime = require("src.mods.Runtime") if not require("src.core.GameVersion").isGen2() or not Runtime.wants("clock.day_changed") then observed[save] = nil return day end - local previous = observed[save] - observed[save] = day -- Commit before dispatch: reentrant reads cannot emit twice. - if previous ~= nil and previous ~= day then - Runtime.emit("clock.day_changed", { day = day, previous = previous, reason = reason }) + local previous, date = observed[save], Clock.dateKey(save, now) + observed[save] = { day=day, date=date } -- Commit before dispatch; reentrant reads are silent. + if previous ~= nil and (previous.day ~= day or previous.date ~= date) then + Runtime.emit("clock.day_changed", { day = day, previous = previous.day, reason = reason }) end return day end function Clock.weekday(save, now) local state = rtc(save) + local total, day = anchored(state, now) + if total then return note(save, (day + math.floor(total / 1440)) % 7, "rollover", now) end local offset = state and integer(state.startDay) if offset == nil then offset = type(save) == "table" and integer(save.g2DayOffset) or 0 end - return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover") + return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover", now) end function Clock.setWeekday(save, day) day = integer(day) if type(save) ~= "table" or not day then return false end + local now = os.time() save.rtc = rtc(save) or {} day = day % 7 - save.rtc.startDay = (day - Clock.hostWeekday()) % 7 + save.rtc.startDay = (day - Clock.hostWeekday(now)) % 7 save.rtc.dayOfWeek = day + local total = anchored(save.rtc, now) + if total then save.rtc.anchorDay = (day - math.floor(total / 1440)) % 7 end -- Keep the existing native field in sync for older native consumers/saves. save.g2DayOffset = save.rtc.startDay - note(save, day, "set") + note(save, day, "set", now) return true end +-- Calendar identity for daily/weekly timers. Only date arithmetic uses the +-- host calendar library; elapsed game days come from the same RTC as minutes +-- and weekday. Noon normalization avoids missing/duplicated local DST hours. +function Clock.calendarDate(save, now) + local state = rtc(save) + local total = anchored(state, now) + local year, month, day + if total and type(state.anchorDate) == "string" then + year, month, day = state.anchorDate:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$") + year, month, day = tonumber(year), tonumber(month), tonumber(day) + end + if year and month >= 1 and month <= 12 and day >= 1 and day <= 31 then + local ok, stamp = pcall(os.time, { year=year, month=month, + day=day + math.floor(total / 1440), hour=12, min=0, sec=0, isdst=nil }) + if ok and stamp then return os.date("*t", stamp) end + end + return os.date("*t", now) +end +function Clock.dateKey(save, now) + local date = Clock.calendarDate(save, now) + return ("%04d-%02d-%02d"):format(date.year, date.month, date.day) +end function Clock.weekdayName(day) local name = Clock.DAY_NAMES[day] return name and Strings(name) diff --git a/src/script/Gen2Daily.lua b/src/script/Gen2Daily.lua index 6337f5cd9..36c041447 100644 --- a/src/script/Gen2Daily.lua +++ b/src/script/Gen2Daily.lua @@ -18,6 +18,9 @@ local Gen2Flags = require("src.script.Gen2Flags") local Gen2Daily = {} +-- Native RTC/calendar integration by Roxas2712; see contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +local Clock = require("src.core.Gen2Clock") local ENGINE_LUCKY_NUMBER_SHOW = 77 local GameVersion = require("src.core.GameVersion") @@ -39,19 +42,15 @@ local INITIAL_HIDDEN_EVENTS = { 1915, -- EVENT_INDIGO_PLATEAU_POKECENTER_RIVAL } -local function todayKey(now) - return os.date("%Y-%m-%d", now) -end - local function nextFriday(save, now) - local date = os.date("*t", now) - local weekday = require("src.core.Gen2Clock").weekday(save, now) + local date = Clock.calendarDate(save, now) + local weekday = Clock.weekday(save, now) local days = (5 - weekday) % 7 -- Match the weekday chosen at Mom's clock. if days == 0 then days = 7 end -- Calendar arithmetic, rather than seconds, also handles daylight saving. date.day, date.hour, date.min, date.sec = date.day + days, 12, 0, 0 date.isdst = nil - return todayKey(os.time(date)) + return os.date("%Y-%m-%d", os.time(date)) end function Gen2Daily.lotteryExpired(save, now) @@ -59,7 +58,7 @@ function Gen2Daily.lotteryExpired(save, now) -- Old saves already have a drawn ID; retain it when adding the timer. save.g2LuckyNextFriday = nextFriday(save, now) end - return todayKey(now) >= save.g2LuckyNextFriday + return Clock.dateKey(save, now) >= save.g2LuckyNextFriday end function Gen2Daily.resetLottery(save, now) @@ -121,7 +120,7 @@ function Gen2Daily.poll(save, now) Gen2Daily.ensureRoam(save, nil) if not save then return end Gen2Daily.seedInitialObjectFlags(save) - local today = todayKey(now) + local today = Clock.dateKey(save, now) if save.g2DailyDay == today then return end save.g2DailyDay = today Gen2Daily.onNewDay(save) diff --git a/tests/engine/gen2_clock.lua b/tests/engine/gen2_clock.lua index e3085943c..ecfe737fe 100644 --- a/tests/engine/gen2_clock.lua +++ b/tests/engine/gen2_clock.lua @@ -7,9 +7,11 @@ local Clock = require('src.core.gen2.Clock') local P = require('src.render.PaletteFX');local W = require('src.world.OverworldController') require('src.script.Gen2Commands');local Commands = require('src.script.Commands');local Loader = require('src.mods.Loader') local Json = require('src.link.Json');local Save = require('src.core.SaveData') -local date = os.date;local host = { hour=13, minute=20, day=2 } +local date,time = os.date,os.time;local elapsed=0;local baseTime=time() +os.time=function(value)if value then return time(value)end;return baseTime+elapsed end +local host = { hour=13, minute=20, day=2 } os.date = function(format, now) - if now then return date(format,now) end + if now and now~=os.time() then return date(format,now) end if format=='%H' then return tostring(host.hour) end if format=='%M' then return tostring(host.minute) end if format=='%w' then return tostring(host.day) end @@ -33,7 +35,7 @@ T.eq(Clock.setTime(false,10,0),false,'no saved state means no write') T.eq(Clock.setWeekday({},math.huge),false,'infinite weekday rejected') T.eq(Clock.weekdayName(0),nil,'weekday label API is one-based') for _,edition in ipairs({'gold','silver','crystal'}) do - V.set(edition);host.hour=13;host.minute=20;host.day=2 + V.set(edition);host.hour=13;host.minute=20;host.day=2;elapsed=0 local save={playTime=0,g2DayOffset=3};local untouched=Json.encode(save) T.eq(Clock.hour(save),13,edition..' legacy hour retains host behavior') T.eq(Clock.weekday(save),5,edition..' legacy native weekday offset retained') @@ -45,10 +47,9 @@ for _,edition in ipairs({'gold','silver','crystal'}) do T.eq(Clock.weekday(save),6,edition..' weekday is observed immediately') T.eq(Clock.isSet(save),true,edition..' explicit clock setting is distinguishable') T.eq(save.g2DayOffset,save.rtc.startDay,edition..' old native day readers retain shared offset') - host.minute=30 + host.minute=30;elapsed=600 T.eq(Clock.minutes(save),5,edition..' RTC keeps ticking and wraps at midnight') - host.day=3 - T.eq(Clock.weekday(save),0,edition..' saved weekday follows RTC weekly rollover') + T.eq(Clock.weekday(save),0,edition..' saved weekday rolls over at game midnight before host midnight') local round=assert(Save.decode(Save.encode(save))) T.eq(Clock.minutes(round),5,edition..' save roundtrip retains RTC minute base') T.eq(Clock.weekday(round),0,edition..' save roundtrip retains RTC weekday base') @@ -115,5 +116,5 @@ for _,edition in ipairs({'gold','silver','crystal'}) do end end nativeGame.save=oldSave;debug.setupvalue(W.timeOfDay,slot,oldGame) -Clock.resetObservations();os.date=date;V.set(version) +Clock.resetObservations();os.date=date;os.time=time;V.set(version) T.finish('shared native Gen2 saved RTC, actual world/scripts/clock-card and public event') diff --git a/tests/engine/gen2_clock_lifecycle.lua b/tests/engine/gen2_clock_lifecycle.lua new file mode 100644 index 000000000..ab38d51c8 --- /dev/null +++ b/tests/engine/gen2_clock_lifecycle.lua @@ -0,0 +1,119 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local version=V.get() +local C=require('src.core.Gen2Clock');local D=require('src.script.Gen2Daily') +local Save=require('src.core.SaveData');local Json=require('src.link.Json') +local date,time=os.date,os.time +-- A deterministic host clock. Date-table normalization still uses the OS +-- calendar, including real DST rules; elapsed time is independently advanced. +local now=time({year=2026,month=10,day=20,hour=13,min=20,sec=0}) +local origin=now;local shift=0 +os.time=function(value)if value then return time(value)end;return now end +os.date=function(format,stamp)return date(format,(stamp or now)+shift)end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);now=origin;shift=0 + local save={flags={},g2FruitTrees={used=true}} + T.eq(C.setTime(save,23,59),true,edition..' player can anchor before midnight') + T.eq(C.setWeekday(save,4),true,edition..' player chooses Thursday independently of host') + local startDate=C.dateKey(save);local offset=save.rtc.startMinute + T.eq(C.hour(save),23,edition..' explicit hour visible immediately') + T.eq(C.minute(save),59,edition..' explicit minute visible immediately') + D.poll(save);save.g2FruitTrees.used=true + local before=save.g2DailyDay + now=now+59;D.poll(save) + T.eq(C.minute(save),59,edition..' RTC waits for the full elapsed minute') + T.eq(C.weekday(save),4,edition..' weekday remains before game midnight') + T.eq(save.g2FruitTrees.used,true,edition..' daily use retained before midnight') + now=now+1;D.poll(save) + T.eq(C.hour(save),0,edition..' elapsed RTC crosses midnight') + T.eq(C.minute(save),0,edition..' minute wraps with the day') + T.eq(C.weekday(save),5,edition..' weekday crosses at same game midnight') + T.eq(C.hostWeekday(),C.hostWeekday(origin),edition..' host has not crossed midnight') + T.eq(save.g2FruitTrees.used,nil,edition..' native daily reset occurs at game midnight') + T.eq(save.g2DailyDay~=before,true,edition..' daily calendar uses the game boundary') + T.eq(C.dateKey(save)~=startDate,true,edition..' RTC calendar advances at same boundary') + save.g2FruitTrees.used=true;D.poll(save) + T.eq(save.g2FruitTrees.used,true,edition..' repeated poll cannot repeat daily reset') + local round=assert(Save.decode(Save.encode(save))) + now=now+7*86400+123*60 + T.eq(C.weekday(round),5,edition..' offline week retains proper weekday') + T.eq(C.hour(round),2,edition..' offline elapsed hours retained after save reload') + T.eq(C.minute(round),3,edition..' offline elapsed minutes retained after save reload') + local stale=round.g2DailyDay;D.poll(round) + T.eq(round.g2DailyDay~=stale,true,edition..' offline whole week still resets daily identity') + T.eq(round.g2FruitTrees.used,nil,edition..' offline reset does not depend on changed weekday') + local expected=C.minutes(round);local day=C.weekday(round);local calendar=C.dateKey(round) + shift=3600 + T.eq(C.minutes(round),expected,edition..' automatic host DST jump cannot double-advance game RTC') + T.eq(C.weekday(round),day,edition..' host DST cannot independently shift game weekday') + T.eq(C.dateKey(round),calendar,edition..' host DST cannot expire daily identity') + shift=-3600 + T.eq(C.minutes(round),expected,edition..' backward host DST jump cannot freeze game RTC') + shift=0 + T.eq(C.setTime(round,7,15),true,edition..' explicit edit reanchors current native clock') + T.eq(C.weekday(round),day,edition..' editing time preserves the chosen current weekday') + T.eq(C.dateKey(round),calendar,edition..' editing time preserves current game calendar date') + T.eq(C.hour(round),7,edition..' edited hour visible') + T.eq(C.minute(round),15,edition..' edited minute visible') + now=now+2*86400+45*60 + T.eq(C.weekday(round),(day+2)%7,edition..' future days follow new anchor') + T.eq(C.minutes(round),8*60,edition..' new anchor keeps ticking') + local oldDate=C.dateKey(round);C.setWeekday(round,0) + T.eq(C.weekday(round),0,edition..' weekday editing after offline elapsed days is immediate') + T.eq(C.dateKey(round),oldDate,edition..' weekday wheel does not rewrite calendar age') + C.setTime(round,23,59);now=now+60 + T.eq(C.weekday(round),1,edition..' edited weekday and time cross next midnight together') + -- The weekly service has to expire on the selected in-game Friday, not a + -- host Friday that is several hours out of phase. + now=origin;local lottery={flags={}};C.setTime(lottery,23,59);C.setWeekday(lottery,4) + D.resetLottery(lottery);T.eq(D.lotteryExpired(lottery),false,edition..' fresh lottery remains active Thursday') + now=now+60 + T.eq(C.weekday(lottery),5,edition..' weekly service reaches selected Friday') + T.eq(D.lotteryExpired(lottery),true,edition..' native lottery expires at game Friday midnight') + D.resetLottery(lottery);T.eq(D.lotteryExpired(lottery),false,edition..' Friday draw waits for next Friday') + now=now+6*86400 + T.eq(D.lotteryExpired(lottery),false,edition..' lottery does not expire a day early') + now=now+86400 + T.eq(D.lotteryExpired(lottery),true,edition..' next game Friday expires weekly draw') + -- Actual subscription dispatch must observe a game midnight even when + -- the host weekday is unchanged, and an offline full week too. + for _,api in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/day/manifest.json']=Json.encode({id='day',name='Native midnight observer',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), + ['mods/day/main.lua']=[[ + local mod=...;mod.exports.events={} + local Clock=require('src.core.gen2.Clock') + mod.events:on('clock.day_changed',function(e) + mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} + assert(Clock.weekday(mod.exports.save)==e.day) + e.day=99 + end) + ]]} + local loaded=T.sdk.loadMods({'mods/day'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,edition..' real loader admits midnight observer') + local export=loaded.loader.exports.day;local own={};export.save=own + now=origin;C.setTime(own,23,59);C.setWeekday(own,4) + -- Explicit staging changes may emit; only changes after the first settled + -- observation are under test here. + C.weekday(own);local n=#export.events;now=now+60;C.weekday(own) + T.eq(#export.events,n+1,edition..' midnight event dispatched once through actual runtime') + T.eq(export.events[n+1].day,5,edition..' midnight event carries new game weekday') + T.eq(export.events[n+1].previous,4,edition..' midnight event retains previous game weekday') + C.weekday(own);T.eq(#export.events,n+1,edition..' reentrant/repeated reads cannot repeat midnight') + now=now+7*86400;C.weekday(own) + T.eq(#export.events,n+2,edition..' offline full week still emits one observed day change') + T.eq(export.events[n+2].day,5,edition..' full-week event correctly has same weekday') + require('src.mods.Loader').teardown();loaded.release() + end + -- Old offsets and old saves remain read-only on load, without introducing + -- an epoch guessed from today's time into another player's save. + local old={rtc={startMinute=offset,startDay=2},g2DayOffset=2} + local bytes=Json.encode(old) + T.eq(C.minutes(old),(C.hostMinutes()+offset)%1440,edition..' old minute offset retains exact semantics') + T.eq(C.weekday(old),(C.hostWeekday()+2)%7,edition..' old weekday offset retains exact semantics') + C.dateKey(old);T.eq(Json.encode(old),bytes,edition..' old save reads do not migrate or mutate state') + local malformed={rtc={anchorTime='bad',anchorMinute=0,anchorDay=0,startMinute=0}} + T.eq(C.minutes(malformed),C.hostMinutes(),edition..' malformed optional anchor falls back to old clock') +end +os.date=date;os.time=time;C.resetObservations();V.set(version) +T.finish('native RTC midnight, offline calendar, host DST isolation and actual daily/lottery lifecycle') From 5c1eafa6c51e6a4c4c7dd6cdbcc7b6c0da816ea9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:47:37 +0100 Subject: [PATCH 048/187] Set the Gen2 clock through the native introduction and weekday screen --- docs/native-gen2-clock.md | 4 +- docs/native-gen2-init-clock.md | 37 +++++++ src/core/ModCompat.lua | 1 + src/script/Gen2Commands.lua | 50 +++------ src/ui/Gen2InitClock.lua | 176 +++++++++++++++++++++++++++++++ src/ui/OakSpeech.lua | 25 ++++- tests/engine/gen2_init_clock.lua | 129 ++++++++++++++++++++++ 7 files changed, 380 insertions(+), 42 deletions(-) create mode 100644 docs/native-gen2-init-clock.md create mode 100644 src/ui/Gen2InitClock.lua create mode 100644 tests/engine/gen2_init_clock.lua diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md index 5fc23558a..8efd85550 100644 --- a/docs/native-gen2-clock.md +++ b/docs/native-gen2-clock.md @@ -65,7 +65,9 @@ card. These checks do not establish a complete original-story clock-setting sequence, manual DST-setting/check dialogs, battery-backed SRAM RTC conversion, all daily services or fresh GPU acceptance for this change. Automatic host DST isolation is covered; the original story clock-setting -screen and remaining manual DST lifecycle still need native implementation. +screen now has a native implementation described in +[native-gen2-init-clock.md](native-gen2-init-clock.md); fresh original-story/GPU +acceptance and the remaining manual DST lifecycle are separately gated. The public contract is compared with the read-only Gen1Recomp reference; the implementation is native fork code. No VASC implementation, private diff --git a/docs/native-gen2-init-clock.md b/docs/native-gen2-init-clock.md new file mode 100644 index 000000000..058717d8a --- /dev/null +++ b/docs/native-gen2-init-clock.md @@ -0,0 +1,37 @@ +# Native Gen2 initial clock and weekday selection + +Gold, Silver and Crystal now open their native Oak speech with an +`init_clock` step. This opens `Gen2InitClock` before the professor's welcome +and music. The required hour and minute pickers wrap in both directions, +retain their selection after NO, read the complete time back, and commit to +the shared saved RTC only after the final acknowledgement. Completion resumes +the original Oak parent once. Other Gen2 introductions keep their existing +step list. + +The native `SetDayOfWeek` special uses the same screen in day mode. It opens +on Sunday and remains suspended through selection and confirmation. NO +returns to the chosen value without writing the save or resuming the script; +YES writes through `Gen2Clock.setWeekday` and resumes the original runner once. + +`src.ui.gen2.InitClock` resolves to this actual native screen. Its constructor +accepts mode, save, hour/minute/day, onDone and autoConfirm options. Common +hour/time/response formatting helpers use the native clock labels. Native +TextBox and ChoiceBox own text paging, typing and YES/NO input; the clock +screen owns picker state, transitions, save commitment and optional fades. + +Dialogue is read from the active imported native text bank. Native wrapper +and underscore text spellings are supported; short engine prompts are used +only if an older cache lacks a line. No ROM dialogue, artwork, palette or +coordinate table is embedded in the implementation. The current panel layout +is engine-owned. This is not a claim of exact reference raster/layout helper +parity; the remaining reference drawing/metadata helpers and original clock +art require separate work. + +ROM-free coverage exercises the actual native widgets and stack, wrapped +selection and NO retries, final acknowledgement, save-codec retention, the +real special compiler/ScriptRunner yield/resume, the real Oak step and loaded +native API1/2 clients. These tests do not establish fresh +GPU or complete natural new-game/story acceptance. Manual DST dialogs/checks, +SRAM RTC conversion, broader story/hardware acceptance and full VASC/Gen1 +parity remain open. Private VASC code and protected Gen1/player instances are +unchanged. diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 420182efc..1ccf52cb4 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -68,6 +68,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", ["src.core.gen2.Clock"] = "src.core.Gen2Clock", + ["src.ui.gen2.InitClock"] = "src.ui.Gen2InitClock", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index d41e01d32..4de6372fc 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2879,45 +2879,21 @@ function Commands.g2_clear_dst(ctx) Commands.show_text(ctx, "InitialClearDSTFlag.Text") end --- The ROM spins these with the d-pad on a graphical clock-set background; --- a list menu is the port's equivalent and needs no ROM tiles. The names --- are engine text, not ROM strings: SetDayOfWeek.WeekdayStrings is raw --- charmap data, and the extractor only harvests symbols matching "Text". -local WEEKDAYS = { - "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", -} - --- `special SetDayOfWeek` ($25), the day picker in mom's clock talk. The GB --- has to ask because its RTC has no idea what day it started on; here --- readvar 11 reads the host clock, which already knows. So the answer is --- kept as an OFFSET from the host day rather than as the day itself: the --- cursor opens on today, a truthful answer stores 0 and changes nothing, and --- either way the day still rolls over at real midnight instead of freezing. +-- Native SetDayOfWeek shares the clock-setting screen and RTC owner with +-- the introduction. The script remains suspended through both selection +-- and confirmation; declining does not write the save or resume the script. function Commands.g2_set_day_of_week(ctx) local game, runner = ctx.game, ctx.runner - local Clock = require("src.core.Gen2Clock") - local current = Clock.weekday(ctx.save) - local picked - repeat - Commands.show_text(ctx, "SetDayOfWeek.OakTimeWhatDayIsItText") - local items = {} - for index, day in ipairs(WEEKDAYS) do - items[index] = { - label = Strings(day), - onSelect = function() picked = index - 1 runner:resume() end, - } - end - -- no cancel: the ROM's loop watches A only, and onCancel is the only - -- other path back to runner:resume() - local menu = require("src.ui.Menu").new(game, items, { cancelable = false }) - menu.index = current + 1 - menu:clampScroll() - game.stack:push(menu) - runner:yield() - Commands.ask(ctx, Strings(WEEKDAYS[picked + 1]) - .. resolvedText(ctx, "SetDayOfWeek.OakTimeIsItText")) - until ctx.lastCheck - Clock.setWeekday(ctx.save, picked) + local screen + screen = require("src.ui.Gen2InitClock").new(game, { mode="day", save=ctx.save, + onDone=function() + if game.stack:top() == screen then game.stack:pop() end + ctx.lastCheck = true + runner:resume() + end, + }) + game.stack:push(screen) + runner:yield() end -- UnownPuzzle (SpecialsPointers row 41). The chamber scripts run diff --git a/src/ui/Gen2InitClock.lua b/src/ui/Gen2InitClock.lua new file mode 100644 index 000000000..e98d9cc6c --- /dev/null +++ b/src/ui/Gen2InitClock.lua @@ -0,0 +1,176 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +-- Native clock/day selection over the shared saved RTC and native widgets. +-- Dialogue comes from the active ROM cache; this layout is engine-owned. +local Clock = require("src.core.Gen2Clock") +local Font = require("src.render.Font") +local Strings = require("src.core.Strings") +local TextBox = require("src.render.TextBox") +local InitClock = {} +InitClock.__index = InitClock +InitClock.isOpaque = true +local function value(n, fallback, limit) + n = tonumber(n) + if not n or n ~= n or n == math.huge or n == -math.huge then return fallback end + return math.floor(n) % limit +end +local function text(self, key, fallback) + local bank = self.game and self.game.data and self.game.data.text or {} + local found = bank[key] or bank["_" .. key:match("[^.]+$")] + if type(found) == "string" and found ~= "" then return found end + return Strings(fallback) +end +function InitClock.hourString(hour) + hour = value(hour, 0, 24) + local shown = hour % 12 + return ("%s %d"):format(Clock.daytimeLabel(hour), shown == 0 and 12 or shown) +end +function InitClock.oclockString(hour) return Strings("%s o'clock", InitClock.hourString(hour)) end +function InitClock.timeString(hour, minute) + return ("%s:%02d"):format(InitClock.hourString(hour), value(minute, 0, 60)) +end +function InitClock.responseKey(hour) + hour = value(hour, 0, 24) + if hour < 4 or hour >= 18 then return "soDark" end + return hour <= 10 and "overslept" or "yikes" +end +function InitClock.new(game, opts) + opts = opts or {} + local day = opts.mode == "day" + return setmetatable({ game=game, save=opts.save or game.save, + mode=day and "day" or "clock", phase=day and "day" or "intro", + isOpaque=not day, onDone=opts.onDone, autoConfirm=opts.autoConfirm == true, + fades=opts.fades == true and not day and not opts.autoConfirm, + hour=value(opts.hour, Clock.DEFAULT_HOUR, 24), + minute=value(opts.minute, Clock.DEFAULT_MINUTE, 60), day=value(opts.day, 0, 7), + }, InitClock) +end +function InitClock:wantsFillScale() return self.mode ~= "day" end +function InitClock:question() + if self.phase == "intro" then return text(self, "OakTimeWokeUpText", "SET THE CLOCK") end + if self.phase == "hour" then return text(self, "OakTimeWhatTimeIsItText", "CHOOSE HOUR") end + if self.phase == "minute" then return text(self, "OakTimeHowManyMinutesText", "CHOOSE MINUTE") end + if self.phase == "day" then return text(self, "SetDayOfWeek.OakTimeWhatDayIsItText", "CHOOSE DAY") end + if self.phase == "confirm-hour" then + return text(self, "OakTimeWhatHoursText", "CONFIRM HOUR") .. "\n" + .. InitClock.oclockString(self.hour) + .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", "?") + end + if self.phase == "confirm-minute" then + return text(self, "OakTimeWhoaMinutesText", "CONFIRM MINUTE") .. "\n" + .. Strings("%d min.", self.minute) + .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", "?") + end + if self.phase == "confirm-day" then + return Clock.weekdayName(self.day + 1) + .. text(self, "SetDayOfWeek.OakTimeIsItText", "?") + end + local response = { soDark="OakTimeSoDarkText", overslept="OakTimeOversleptText", yikes="OakTimeYikesText" } + if self.phase == "response" then + return InitClock.timeString(self.hour, self.minute) + .. text(self, "OakText_ResponseToSetTime." .. response[InitClock.responseKey(self.hour)], "\nCLOCK SET") + end + return "" +end +function InitClock:display() + if self.phase == "hour" then return InitClock.oclockString(self.hour) end + if self.phase == "minute" then return Strings("%d min.", self.minute) end + if self.phase == "day" then return Clock.weekdayName(self.day + 1) end +end +function InitClock:confirming() return self.phase:match("^confirm%-") ~= nil end +function InitClock:showPhase() + if self.autoConfirm or self.done then return end + if self.phase == "intro" or self.phase == "response" then + self.game.stack:push(TextBox.new(self.game, self:question(), function() self:accept() end)) + elseif self:confirming() then + self.game.stack:push(TextBox.new(self.game, self:question(), nil, { + choice=function(yes) if yes then self:accept() else self:decline() end end, + })) + end +end +function InitClock:enter() + if self.fades then self.fade = { out=false, frame=0 } else self:showPhase() end +end +function InitClock:complete() + if self.done then return end + self.done = true + if self.onDone then + if self.mode == "day" then self.onDone(self.day) else self.onDone(self.hour, self.minute) end + elseif self.game.stack:top() == self then + self.game.stack:pop() + end +end +function InitClock:finish() + if self.done or self.finishing then return end + local ok + if self.mode == "day" then ok = Clock.setWeekday(self.save, self.day) + else ok = Clock.setTime(self.save, self.hour, self.minute) end + if not ok then return end + self.finishing = true + if self.fades then self.fade = { out=true, frame=0 } else self:complete() end +end +function InitClock:accept() + if self.done or self.finishing then return end + local nextPhase = { intro="hour", hour="confirm-hour", ["confirm-hour"]="minute", + minute="confirm-minute", ["confirm-minute"]="response", day="confirm-day" } + local next = nextPhase[self.phase] + if next then self.phase=next;self:showPhase() else self:finish() end +end +function InitClock:decline() + if self.done or self.finishing then return end + local picker = self.phase:match("^confirm%-(.+)$") + if picker then self.phase=picker;self:showPhase() end +end +function InitClock:value() + if self.phase == "hour" then return self.hour, 23 end + if self.phase == "minute" then return self.minute, 59 end + return self.day, 6 +end +function InitClock:step(delta) + local current, last = self:value() + local key = self.phase + if key == "hour" or key == "minute" or key == "day" then self[key]=(current+delta)%(last+1) end +end +function InitClock:update() + if self.done then return end + if self.fade then + local fade = self.fade + fade.frame = fade.frame + 1 + if fade.frame >= 8 then + self.fade = nil + if fade.out then self:complete() else self:showPhase() end + end + return + end + if self.finishing then return end + if self.autoConfirm then self:accept();return end + local input = self.game.input + if input:wasPressed("up") then self:step(1) + elseif input:wasPressed("down") then self:step(-1) + elseif input:wasPressed("a") then self:accept() end +end +function InitClock:sgbPalettes() + local P = require("src.render.PaletteFX") + if self.isOpaque then return { P.trueColorZone(0, 0, 19, 17) } end + return { P.trueColorZone(1, 4, 18, 9), P.trueColorZone(0, 12, 19, 17) } +end +function InitClock:draw() + local G = love.graphics + G.setColor(1, 1, 1, 1) + if self.isOpaque then G.rectangle("fill", 0, 0, 160, 144) end + Font.drawBox(1, 4, 18, 6) + local shown = self:display() or InitClock.timeString(self.hour, self.minute) + Font.draw(shown, 16, 48) + Font.draw(Strings("UP/DOWN: CHANGE"), 16, 64) + Font.draw(Strings("A: CONFIRM"), 16, 80) + if self:display() then + Font.drawBox(0, 12, 20, 6) + Font.draw(self:question(), 8, 105) + end + if self.fade then + local alpha = self.fade.frame / 8 + G.setColor(0, 0, 0, self.fade.out and alpha or 1-alpha) + G.rectangle("fill", 0, 0, 160, 144) + end + G.setColor(1, 1, 1, 1) +end +return InitClock diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index ed0f11d16..d2bcda421 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -281,7 +281,7 @@ end -- over a Gen2 save. The keys are the ROM's own text labels, so textOr finds -- them in data.text instead of falling through. function OakSpeech.gen2Steps() - return { + local steps = { { id = "oak_welcome", kind = "say", @@ -384,6 +384,11 @@ function OakSpeech.gen2Steps() kind = "shrink", }, } + local version = GameVersion.get() + if version == "gold" or version == "silver" or version == "crystal" then + table.insert(steps, 1, { id="init_clock", kind="initclock" }) + end + return steps end -- list helpers for intro.oak_speech.build wrappers (also on ModUI) @@ -489,10 +494,12 @@ function OakSpeech:buildSteps() end function OakSpeech:enter() - -- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48) - Music.play(self.game.data, self.cfg.music or "Music_Routes2") self.answers = {} self.steps = self:buildSteps() + -- The retail clock comes before the professor and his music. + if not (self.steps[1] and self.steps[1].kind == "initclock") then + Music.play(self.game.data, self.cfg.music or "Music_Routes2") + end if Runtime.wants("intro.oak_speech.started") then Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps }) end @@ -660,7 +667,17 @@ end function OakSpeech:runStep(step) local kind = step.kind or "say" - if kind == "say" then + if kind == "initclock" then + local screen + screen = require("src.ui.Screens").push(self.game, "Gen2InitClock", { + save=self.game.save, fades=true, + onDone=function() + if self.game.stack:top() == screen then self.game.stack:pop() end + Music.play(self.game.data, self.cfg.music or "Music_Routes2") + self:advance() + end, + }) + elseif kind == "say" then self:applyPic(step) self:afterReveal(step, function() self:runCry(step) diff --git a/tests/engine/gen2_init_clock.lua b/tests/engine/gen2_init_clock.lua new file mode 100644 index 000000000..c8ebbbe0d --- /dev/null +++ b/tests/engine/gen2_init_clock.lua @@ -0,0 +1,129 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local UI=require('src.ui.Gen2InitClock');local Clock=require('src.core.Gen2Clock') +local Stack=require('src.core.StateStack');local Box=require('src.render.TextBox');local Choice=require('src.ui.ChoiceBox') +local Timing=require('src.core.Timing');local Screens=require('src.ui.Screens') +local Json=require('src.link.Json');local Save=require('src.core.SaveData') +local function game() + local g={data=T.fixtures.fresh(),save={player={name='QA'},flags={},options={}},input={queue={}}} + g.input.wasPressed=function(self,k)return self.queue[k]==true end;g.input.isDown=function()return false end + g.stack=setmetatable({}, {__index=Stack});g.stack:init() + g.data.text={OakTimeWokeUpText='QA opening page.\fQA next page.',OakTimeWhatTimeIsItText='QA choose hour.', + OakTimeHowManyMinutesText='QA choose minute.',OakTimeWhatHoursText='QA hour',OakTimeWhoaMinutesText='QA minute', + ['OakTimeWhatHoursText.OakTimeHoursQuestionMarkText']='?', + ['OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText']='?', + ['OakText_ResponseToSetTime.OakTimeSoDarkText']=' QA night response.', + ['SetDayOfWeek.OakTimeWhatDayIsItText']='QA choose weekday.',['SetDayOfWeek.OakTimeIsItText']=' QA confirm?'} + return g +end +local function tick(g,key,n) + g.input.queue=key and {[key]=true}or{} + for _=1,n or 1 do g.stack:update(1/60)end + g.input.queue={} +end +local function untilState(g,fn,label) + for _=1,2500 do if fn()then return end;tick(g,'a')end + error('native widgets did not reach '..label) +end +local function picker(g,screen,phase) + untilState(g,function()return g.stack:top()==screen and screen.phase==phase and not screen.fade end,phase) +end +local function choice(g) + untilState(g,function()return getmetatable(g.stack:top())==Choice end,'YES/NO over native question') + return g.stack:top() +end +local function answer(g,yes) + choice(g);tick(g,yes and 'a'or'b');tick(g,nil,Timing.YES_NO_ANSWER) +end +T.eq(require('src.ui.gen2.InitClock'),UI,'public reference spelling resolves actual native screen') +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);local label=edition..' ' + T.eq(require('src.ui.OakSpeech').gen2Steps()[1].id,'init_clock',label..' native speech opens with clock') + local g=game();local n=0;local parent={};g.stack:push(parent) + local ui=Screens.push(g,'Gen2InitClock',{fades=true,onDone=function()n=n+1;g.stack:pop()end}) + T.eq(getmetatable(ui),UI,label..' actual native screen factory used') + T.eq(Clock.isSet(g.save),false,label..' opening screen cannot set clock') + picker(g,ui,'hour') + T.eq(ui:question(),'QA choose hour.',label..' question uses imported native text') + T.eq(ui.hour,10,label..' initial picker has native default') + tick(g,'b');T.eq(g.stack:top(),ui,label..' B cannot abandon required picker') + for _=1,10 do tick(g,'down')end + tick(g,'down');T.eq(ui.hour,23,label..' hour wraps backwards through midnight') + tick(g,'up');T.eq(ui.hour,0,label..' hour wraps forward through midnight') + tick(g,'down');tick(g,'a');local c=choice(g) + T.eq(getmetatable(g.stack.states[#g.stack.states-1]),Box,label..' confirmation retains question under choice') + T.eq(Clock.isSet(g.save),false,label..' hour confirmation has not committed clock') + answer(g,false);T.eq(ui.phase,'hour',label..' declining returns to picker') + T.eq(ui.hour,23,label..' declining retains selected hour') + tick(g,'a');answer(g,true);picker(g,ui,'minute') + tick(g,'down');T.eq(ui.minute,59,label..' minute wraps backwards') + tick(g,'up');T.eq(ui.minute,0,label..' minute wraps forwards') + tick(g,'down');tick(g,'a');answer(g,false) + T.eq(ui.phase,'minute',label..' declining minutes returns without saving') + T.eq(Clock.isSet(g.save),false,label..' declining leaves save unchanged') + tick(g,'a');answer(g,true) + T.eq(ui.phase,'response',label..' both confirmed values are read back') + T.eq(ui:question():find('QA night response.',1,true)~=nil,true,label..' response uses imported native fragment') + T.eq(Clock.isSet(g.save),false,label..' response acknowledgement still owns commitment') + untilState(g,function()return n==1 end,'clock completion') + T.eq(g.stack:top(),parent,label..' completion restores exact parent') + T.eq(Clock.hour(g.save),23,label..' confirmed hour reaches native RTC') + T.eq(Clock.minute(g.save),59,label..' confirmed minute reaches native RTC') + local round=assert(Save.decode(Save.encode(g.save))) + T.eq(Clock.minute(round),59,label..' normal save codec preserves confirmed RTC') + ui:finish();ui:complete();T.eq(n,1,label..' duplicate finish cannot repeat callback') + -- Real special lowering and coroutine runner stay suspended through NO. + g=game();local Runner=require('src.script.ScriptRunner');local runner=Runner.new(g,{}) + local rows=assert(require('src.script.Gen2ScriptVM').compile({map_scripts={scripts={qa={ + {'special',37,'SetDayOfWeek'},{'end'}}}}},'qa')) + T.eq(#Runner.validate(rows),0,label..' native special lowers to valid production commands') + local completed=0;runner:run(rows,{onDone=function()completed=completed+1 end}) + local day=g.stack:top();T.eq(getmetatable(day),UI,label..' actual special opens shared native day mode') + T.eq(runner:isRunning(),true,label..' actual script waits for selection') + T.eq(day.day,0,label..' native day picker opens on Sunday') + tick(g,'down');T.eq(day.day,6,label..' weekday wraps backwards') + tick(g,'a');answer(g,false) + T.eq(runner:isRunning(),true,label..' NO cannot resume original script') + T.eq(g.save.rtc,nil,label..' NO cannot write weekday anchor') + tick(g,'up');T.eq(day.day,0,label..' weekday wraps forwards') + tick(g,'a');answer(g,true) + T.eq(Clock.weekday(g.save),0,label..' accepted special writes shared weekday') + T.eq(runner:isRunning(),false,label..' accepted special resumes real runner') + T.eq(completed,1,label..' real runner completes once') + day:finish();T.eq(completed,1,label..' repeated special finish cannot resume twice') + -- The production Oak step must yield to the same child and advance once. + g=game();local advanced=0;local speech={game=g,cfg={},advance=function()advanced=advanced+1 end} + g.stack:push(parent);require('src.ui.OakSpeech').runStep(speech,{kind='initclock'}) + local child=g.stack:top();T.eq(getmetatable(child),UI,label..' real Oak step opens native clock') + picker(g,child,'hour');tick(g,'a');answer(g,true);picker(g,child,'minute') + tick(g,'a');answer(g,true);untilState(g,function()return advanced==1 end,'Oak resumes') + T.eq(g.stack:top(),parent,label..' Oak clock completion preserves parent ownership') + T.eq(Clock.isSet(g.save),true,label..' actual Oak continuation follows committed RTC') + for _,api in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/setclock/manifest.json']=Json.encode({id='setclock',name='Native clock UI client',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), + ['mods/setclock/main.lua']=[[ + local mod=...;local Screen=require('src.ui.gen2.InitClock') + mod.exports.make=function(game)return Screen.new(game,{autoConfirm=true})end + ]]} + local loaded=T.sdk.loadMods({'mods/setclock'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,label..' actual loader admits native clock UI client') + local own=game();local screen=loaded.loader.exports.setclock.make(own) + T.eq(getmetatable(screen),UI,label..' loaded client uses native owner instead of a stub') + own.stack:push(screen) + untilState(own,function()return screen.done end,'loaded clock UI completion') + T.eq(Clock.isSet(own.save),true,label..' loaded UI client commits native RTC') + T.eq(Clock.hour(own.save),10,label..' loaded UI client keeps native default hour') + require('src.mods.Loader').teardown();loaded.release() + end +end +for _,edition in ipairs({'prism','polishedcrystal'})do + V.set(edition);T.eq(require('src.ui.OakSpeech').gen2Steps()[1].id,'oak_welcome',edition..' keeps its own introduction') +end +for _,hour in ipairs({0,3,4,10,11,17,18,23})do + local response=UI.responseKey(hour) + T.eq(response,(hour<4 or hour>=18)and'soDark'or(hour<=10 and'overslept'or'yikes'),'hour response boundary '..hour) +end +V.set(version);Clock.resetObservations() +T.finish('native initial clock/day widgets, original Oak admission and actual script continuation') From b2572b8aeb85cdb4a65e302fcd6b00bd0154a87a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:51:55 +0100 Subject: [PATCH 049/187] Register native clock fallback prompts for translation extraction --- src/ui/Gen2InitClock.lua | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/ui/Gen2InitClock.lua b/src/ui/Gen2InitClock.lua index e98d9cc6c..8c5e9e7a2 100644 --- a/src/ui/Gen2InitClock.lua +++ b/src/ui/Gen2InitClock.lua @@ -8,6 +8,12 @@ local TextBox = require("src.render.TextBox") local InitClock = {} InitClock.__index = InitClock InitClock.isOpaque = true +local TEXT = { + intro=Strings.source("SET THE CLOCK"), hour=Strings.source("CHOOSE HOUR"), + minute=Strings.source("CHOOSE MINUTE"), day=Strings.source("CHOOSE DAY"), + confirmHour=Strings.source("CONFIRM HOUR"), confirmMinute=Strings.source("CONFIRM MINUTE"), + questionMark=Strings.source("?"), response=Strings.source("\nCLOCK SET"), +} local function value(n, fallback, limit) n = tonumber(n) if not n or n ~= n or n == math.huge or n == -math.huge then return fallback end @@ -46,28 +52,28 @@ function InitClock.new(game, opts) end function InitClock:wantsFillScale() return self.mode ~= "day" end function InitClock:question() - if self.phase == "intro" then return text(self, "OakTimeWokeUpText", "SET THE CLOCK") end - if self.phase == "hour" then return text(self, "OakTimeWhatTimeIsItText", "CHOOSE HOUR") end - if self.phase == "minute" then return text(self, "OakTimeHowManyMinutesText", "CHOOSE MINUTE") end - if self.phase == "day" then return text(self, "SetDayOfWeek.OakTimeWhatDayIsItText", "CHOOSE DAY") end + if self.phase == "intro" then return text(self, "OakTimeWokeUpText", TEXT.intro) end + if self.phase == "hour" then return text(self, "OakTimeWhatTimeIsItText", TEXT.hour) end + if self.phase == "minute" then return text(self, "OakTimeHowManyMinutesText", TEXT.minute) end + if self.phase == "day" then return text(self, "SetDayOfWeek.OakTimeWhatDayIsItText", TEXT.day) end if self.phase == "confirm-hour" then - return text(self, "OakTimeWhatHoursText", "CONFIRM HOUR") .. "\n" + return text(self, "OakTimeWhatHoursText", TEXT.confirmHour) .. "\n" .. InitClock.oclockString(self.hour) - .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", "?") + .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", TEXT.questionMark) end if self.phase == "confirm-minute" then - return text(self, "OakTimeWhoaMinutesText", "CONFIRM MINUTE") .. "\n" + return text(self, "OakTimeWhoaMinutesText", TEXT.confirmMinute) .. "\n" .. Strings("%d min.", self.minute) - .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", "?") + .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", TEXT.questionMark) end if self.phase == "confirm-day" then return Clock.weekdayName(self.day + 1) - .. text(self, "SetDayOfWeek.OakTimeIsItText", "?") + .. text(self, "SetDayOfWeek.OakTimeIsItText", TEXT.questionMark) end local response = { soDark="OakTimeSoDarkText", overslept="OakTimeOversleptText", yikes="OakTimeYikesText" } if self.phase == "response" then return InitClock.timeString(self.hour, self.minute) - .. text(self, "OakText_ResponseToSetTime." .. response[InitClock.responseKey(self.hour)], "\nCLOCK SET") + .. text(self, "OakText_ResponseToSetTime." .. response[InitClock.responseKey(self.hour)], TEXT.response) end return "" end From 5fb4cc1da535b94e235925cbdc470f3d832ae6ef Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:56:58 +0100 Subject: [PATCH 050/187] Keep native clock text separators outside translatable prompts --- src/ui/Gen2InitClock.lua | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/ui/Gen2InitClock.lua b/src/ui/Gen2InitClock.lua index 8c5e9e7a2..6b89d1a86 100644 --- a/src/ui/Gen2InitClock.lua +++ b/src/ui/Gen2InitClock.lua @@ -57,14 +57,16 @@ function InitClock:question() if self.phase == "minute" then return text(self, "OakTimeHowManyMinutesText", TEXT.minute) end if self.phase == "day" then return text(self, "SetDayOfWeek.OakTimeWhatDayIsItText", TEXT.day) end if self.phase == "confirm-hour" then - return text(self, "OakTimeWhatHoursText", TEXT.confirmHour) .. "\n" - .. InitClock.oclockString(self.hour) - .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", TEXT.questionMark) + local lines = { text(self, "OakTimeWhatHoursText", TEXT.confirmHour), + InitClock.oclockString(self.hour) + .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", TEXT.questionMark) } + return table.concat(lines, "\n") end if self.phase == "confirm-minute" then - return text(self, "OakTimeWhoaMinutesText", TEXT.confirmMinute) .. "\n" - .. Strings("%d min.", self.minute) - .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", TEXT.questionMark) + local lines = { text(self, "OakTimeWhoaMinutesText", TEXT.confirmMinute), + Strings("%d min.", self.minute) + .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", TEXT.questionMark) } + return table.concat(lines, "\n") end if self.phase == "confirm-day" then return Clock.weekdayName(self.day + 1) From d4f6aa5043a2409abacccc46ff7e52b4c03f2fad Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:06:59 +0100 Subject: [PATCH 051/187] Preserve other editions intro music and build-hook ordering --- src/ui/OakSpeech.lua | 6 +++++- tests/engine/gen2_init_clock.lua | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index d2bcda421..87fda58a2 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -494,10 +494,14 @@ function OakSpeech:buildSteps() end function OakSpeech:enter() + local version = GameVersion.get() + local clockIntro = version == "gold" or version == "silver" or version == "crystal" + -- Other introductions retain their original music-before-build ordering. + if not clockIntro then Music.play(self.game.data, self.cfg.music or "Music_Routes2") end self.answers = {} self.steps = self:buildSteps() -- The retail clock comes before the professor and his music. - if not (self.steps[1] and self.steps[1].kind == "initclock") then + if clockIntro and not (self.steps[1] and self.steps[1].kind == "initclock") then Music.play(self.game.data, self.cfg.music or "Music_Routes2") end if Runtime.wants("intro.oak_speech.started") then diff --git a/tests/engine/gen2_init_clock.lua b/tests/engine/gen2_init_clock.lua index c8ebbbe0d..15e36e5cb 100644 --- a/tests/engine/gen2_init_clock.lua +++ b/tests/engine/gen2_init_clock.lua @@ -118,6 +118,25 @@ for _,edition in ipairs({'gold','silver','crystal'})do require('src.mods.Loader').teardown();loaded.release() end end +-- The shared Oak class also serves other editions. Preserve their existing +-- music-before-build contract rather than changing Gen1 while adding GSC. +local Music=require('src.core.Music');local play=Music.play;local order +Music.play=function(...)order[#order+1]='music';return play(...)end +for _,edition in ipairs({'red','blue','yellow','prism','polishedcrystal','gold','silver','crystal'})do + V.set(edition);order={} + local native=edition=='gold'or edition=='silver'or edition=='crystal' + local speech={game=game(),cfg={},buildSteps=function() + order[#order+1]='build';return {{kind=native and'initclock'or'say'}} + end,advance=function()order[#order+1]='advance'end} + require('src.ui.OakSpeech').enter(speech) + T.same(order,native and{'build','advance'}or{'music','build','advance'},edition..' preserves appropriate native music/build ordering') + if native then + order={};speech.buildSteps=function()order[#order+1]='build';return {{kind='say'}}end + require('src.ui.OakSpeech').enter(speech) + T.same(order,{'build','music','advance'},edition..' explicit clock removal still starts professor music') + end +end +Music.play=play for _,edition in ipairs({'prism','polishedcrystal'})do V.set(edition);T.eq(require('src.ui.OakSpeech').gen2Steps()[1].id,'oak_welcome',edition..' keeps its own introduction') end From ae0384d3088d39b1198e0d11daba9fa87c6bf0fc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:08:32 +0100 Subject: [PATCH 052/187] Keep clock picker hints inside their panel and hide inactive controls --- src/ui/Gen2InitClock.lua | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ui/Gen2InitClock.lua b/src/ui/Gen2InitClock.lua index 6b89d1a86..bf37c8436 100644 --- a/src/ui/Gen2InitClock.lua +++ b/src/ui/Gen2InitClock.lua @@ -159,18 +159,19 @@ end function InitClock:sgbPalettes() local P = require("src.render.PaletteFX") if self.isOpaque then return { P.trueColorZone(0, 0, 19, 17) } end - return { P.trueColorZone(1, 4, 18, 9), P.trueColorZone(0, 12, 19, 17) } + return { P.trueColorZone(1, 4, 18, 11), P.trueColorZone(0, 12, 19, 17) } end function InitClock:draw() local G = love.graphics G.setColor(1, 1, 1, 1) if self.isOpaque then G.rectangle("fill", 0, 0, 160, 144) end - Font.drawBox(1, 4, 18, 6) - local shown = self:display() or InitClock.timeString(self.hour, self.minute) + Font.drawBox(1, 4, 18, 8) + local picker = self:display() + local shown = picker or InitClock.timeString(self.hour, self.minute) Font.draw(shown, 16, 48) - Font.draw(Strings("UP/DOWN: CHANGE"), 16, 64) - Font.draw(Strings("A: CONFIRM"), 16, 80) - if self:display() then + if picker then + Font.draw(Strings("UP/DOWN: CHANGE"), 16, 64) + Font.draw(Strings("A: CONFIRM"), 16, 80) Font.drawBox(0, 12, 20, 6) Font.draw(self:question(), 8, 105) end From 9db5531ad433f3e297ab84fe33724e44c0af691b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:46:18 +0100 Subject: [PATCH 053/187] Share native BGP ownership and remap already-coloured palette frames --- docs/NATIVE-GEN2-PALETTE-REGISTER.md | 66 +++++++++++ src/render/Gen2PaletteDraw.lua | 154 +++++++++++++++++++++++-- src/render/PaletteFX.lua | 46 ++++++-- tests/engine/gen2_palette_draw.lua | 2 +- tests/engine/gen2_palette_register.lua | 97 ++++++++++++++++ tests/gpu/gen2_palette_register.lua | 89 ++++++++++++++ 6 files changed, 439 insertions(+), 15 deletions(-) create mode 100644 docs/NATIVE-GEN2-PALETTE-REGISTER.md create mode 100644 tests/engine/gen2_palette_register.lua create mode 100644 tests/gpu/gen2_palette_register.lua diff --git a/docs/NATIVE-GEN2-PALETTE-REGISTER.md b/docs/NATIVE-GEN2-PALETTE-REGISTER.md new file mode 100644 index 000000000..10d840c11 --- /dev/null +++ b/docs/NATIVE-GEN2-PALETTE-REGISTER.md @@ -0,0 +1,66 @@ +# Native palette register and frame remapping + +The public `src.render.GbcPalette` module resolves to the engine's +`Gen2PaletteDraw`. Its byte-register API shares `PaletteFX`'s native shade map; +there is no second register owned by a compatibility facade. + +## Forward drawing + +`BGP_IDENTITY` is `0xe4`. `bgpShades(byte)` returns the four two-bit fields, +with colour zero first, in a one-based array. `remap(colors, byte)` applies +those indices to a four-row RGB palette. Nil and identity preserve the original +palette table. + +`setBgp(byte)` returns the previous byte. Identity is represented by nil. +Reading or assigning `GbcPalette.bgp` accesses this same native state; native +`PaletteFX.setShadeMap` writes are immediately visible through it. The existing +native frame reset clears it as well. + +`resolve(colors)` selects the display palette without applying the register. +`color`, `use`, `with` and keyed draws apply the current register once. +`useRaw` and `withRaw` bypass display selection and the register. Native +`PaletteFX.sendColors` consumes the same state. The separate native palette-upload optimization, when +present, consumes that state as well. + +## Already-coloured frames + +`remapTable(bgPalettes, byte, objPalettes, ramp, objRamped)` returns source +colours, destination colours, a live count and an ambiguity count. Tables hold +0–255 RGB triples, padded to `REMAP_MAX` (64) with detached rows. BG palettes +take the explicit byte permutation. OBJ palettes normally retain their colours. +An optional forced ramp supplies BG destinations; selected `objRamped` entries +take its unpermuted colours. + +Shared RGB values can no longer identify the original palette after composition. +The first BG interpretation wins; conflicting destinations increase the ambiguity +count. This is a limitation of remapping RGB frames, not proof of indexed +per-pixel equivalence. Extra entries beyond 64 are omitted with BG slots first. + +`remapUniforms(...)` builds normalized shader inputs, or returns nil for an empty +table. `useRemapUniforms(inputs)` and `useRemap(...)` bind the actual remap shader +and return `true, ambiguityCount` on success. Matching uses squared RGB distance +below `(3/255)^2`; unmatched pixels retain their RGB. Alpha and draw tint are +preserved. Compilation/upload failure returns false, retaining the previous +shader. A failed remap shader can recover when the graphics compiler changes. + +Public uniform uploads are unconditional. Nested `with`, `withRaw` and +`keyedWith` restore both shader identity and prior palette/remap uniforms, +including after a callback or partial upload fails. + +## Verification and remaining display work + +`tests/engine/gen2_palette_register.lua` checks all 256 bytes against independent +field decoding in Red, Gold, Silver and Crystal, native/public register ownership, +BG/OBJ remapping, collisions, forced ramps, bounds, uniform ownership and failures. +`tests/gpu/gen2_palette_register.lua` verifies actual pixels for all 256 bytes +through public and native shaders, including the optimized native upload path, +OBJ preservation, unrelated RGB, alpha and tint. Run `run()` from an isolated +LÖVE host separately on each backend. It requires no cartridge or private mod. + +This change does not implement full Gen1 display parity. Native Gen2 still needs +the DMG/Classic/custom display selections, their cached tile/sprite/battle +invalidation and option persistence. Those require the real rendering owners; +adding setters that only change a public mode string would not implement them. + +The public method protocol follows the Gen1Recomp reference interface. Existing +Gen1Recomp and upstream credits and licences remain applicable; see `NOTICE.md`. diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua index 8d3b93f2e..f84798c13 100644 --- a/src/render/Gen2PaletteDraw.lua +++ b/src/render/Gen2PaletteDraw.lua @@ -13,6 +13,10 @@ setmetatable(M, {__index = function(_, key) if Palettes.mode == "og" or Palettes.mode == "og_inv" then return "dmg" end return Palettes.mode == "classic" and "classic" or "gbc" end + if key == "bgp" then return Palettes.bgp() end +end, __newindex = function(self, key, value) + if key == "bgp" then Palettes.setBgp(value) + else rawset(self, key, value) end end}) local compiler, shaders, failed = nil, {}, {} local sent = setmetatable({}, {__mode = "k"}) @@ -27,26 +31,59 @@ vec4 effect(vec4 tint, Image image, vec2 uv, vec2 screen) { } ]] +-- A composited CGB frame has lost its palette indices. Match only colours +-- present in its BG/OBJ tables; unrelated RGB art must keep its own colour. +-- Constant loop bounds also work on GLSL ES uniform-array implementations. +M.REMAP_MAX = 64 +M.REMAP_TOLERANCE = (3 / 255) ^ 2 +local remapSource = [[ +extern int remapCount; +extern float remapTol; +extern vec3 remapSrc[64]; +extern vec3 remapDst[64]; +vec4 effect(vec4 tint, Image image, vec2 uv, vec2 screen) { + vec4 pixel = Texel(image, uv); + vec3 rgb = pixel.rgb; + float closest = remapTol; + for (int i = 0; i < 64; i++) { + if (i >= remapCount) break; + vec3 delta = pixel.rgb - remapSrc[i]; + float distanceSquared = dot(delta, delta); + if (distanceSquared < closest) { + closest = distanceSquared; + rgb = remapDst[i]; + } + } + return vec4(rgb, pixel.a) * tint; +} +]] + local function graphics() return love and love.graphics end -local function shader(keyed) +local function compile(key, code, keyed) local g = graphics() local newShader = g and g.newShader if type(newShader) ~= "function" then return nil end if compiler ~= newShader then compiler, shaders, failed = newShader, {}, {} end - local key = keyed and "keyed" or "normal" if failed[key] then return nil end if not shaders[key] then - local alpha = keyed and "(shade < 0.5 ? 0.0 : pixel.a)" or "pixel.a" - local ok, value = pcall(newShader, (source:gsub("ALPHA", alpha))) + if key ~= "remap" then + local alpha = keyed and "(shade < 0.5 ? 0.0 : pixel.a)" or "pixel.a" + code = (code:gsub("ALPHA", alpha)) + end + local ok, value = pcall(newShader, code) if not ok or not value then failed[key] = true; return nil end shaders[key] = value end return shaders[key] end +local function shader(keyed) + return compile(keyed and "keyed" or "normal", source, keyed) +end function M.shader() return shader(false) end function M.keyedShader() return shader(true) end +function M.remapShader() return compile("remap", remapSource) end function M.available() return M.shader() ~= nil end local function copy(colors) @@ -65,9 +102,13 @@ local function copy(colors) end return out end -function M.resolve(colors) return Palettes.effectiveColors(colors) end +M.BGP_IDENTITY = Palettes.BGP_IDENTITY +M.bgpShades = Palettes.bgpShades +M.remap = Palettes.remap +M.setBgp = Palettes.setBgp +function M.resolve(colors) return Palettes.displayColors(colors) end function M.color(colors, index) - local palette = M.resolve(colors) + local palette = Palettes.effectiveColors(colors or Palettes.GRAYS) return palette and palette[index] or nil end @@ -84,7 +125,7 @@ end local function use(colors, keyed, raw) local g = graphics() if not (g and g.setShader) then return false end - colors = copy(raw and colors or M.resolve(colors)) + colors = copy(raw and colors or Palettes.effectiveColors(colors)) if not colors then return false end local sh = shader(keyed) if not sh then return false end @@ -100,12 +141,110 @@ function M.clear() if g and g.setShader then g.setShader() end end +-- The first BG slot wins when the same RGB value has different meanings. +-- Report those collisions rather than pretending an RGB frame retains its +-- original indices. OBJ rows normally map to themselves, protecting sprites. +function M.remapTable(bgPalettes, byte, objPalettes, ramp, objRamped) + local src, dst, seen, ambiguous = {}, {}, {}, 0 + local function key(c) + return math.floor(c[1]) .. "," .. math.floor(c[2]) .. "," .. math.floor(c[3]) + end + local function add(colors, mapped) + colors, mapped = copy(colors), copy(mapped) + if not (colors and mapped) then return end + for i = 1, 4 do + local from, to = colors[i], mapped[i] + local k = key(from) + local at = seen[k] + if at then + if key(dst[at]) ~= key(to) then ambiguous = ambiguous + 1 end + elseif #src < M.REMAP_MAX then + src[#src + 1], dst[#dst + 1] = from, to + seen[k] = #src + end + end + end + local forced = ramp and M.resolve(copy(ramp)) + local forcedBg = forced and M.remap(forced, byte) + for _, colors in ipairs(bgPalettes or {}) do + local resolved = M.resolve(copy(colors)) + add(resolved, forcedBg or M.remap(resolved, byte)) + end + if forced then add(forced, forcedBg) end + for i, colors in ipairs(objPalettes or {}) do + local resolved = M.resolve(copy(colors)) + add(resolved, forced and objRamped and objRamped[i] and forced or resolved) + end + local count = #src + for i = count + 1, M.REMAP_MAX do + src[i] = src[1] and {unpack(src[1])} or {0, 0, 0} + dst[i] = dst[1] and {unpack(dst[1])} or {0, 0, 0} + end + return src, dst, count, ambiguous +end +function M.remapUniforms(bgPalettes, byte, objPalettes, ramp, objRamped) + local src, dst, count, ambiguous = M.remapTable(bgPalettes, byte, objPalettes, ramp, objRamped) + if count == 0 then return nil end + for i = 1, M.REMAP_MAX do + for c = 1, 3 do src[i][c], dst[i][c] = src[i][c]/255, dst[i][c]/255 end + end + return {src=src, dst=dst, count=count, ambiguous=ambiguous} +end +local remapSent = setmetatable({}, {__mode = "k"}) +local function uploadRemap(sh, uniforms) + remapSent[sh] = nil + sh:send("remapCount", uniforms.count) + sh:send("remapTol", M.REMAP_TOLERANCE) + sh:send("remapSrc", unpack(uniforms.src)) + sh:send("remapDst", unpack(uniforms.dst)) + remapSent[sh] = uniforms +end +local function copyUniforms(uniforms) + if type(uniforms) ~= "table" or type(uniforms.count) ~= "number" + or uniforms.count < 1 or uniforms.count > M.REMAP_MAX + or uniforms.count % 1 ~= 0 then return nil end + local result = {count=uniforms.count, ambiguous=uniforms.ambiguous, src={}, dst={}} + for _, name in ipairs({"src", "dst"}) do + if type(uniforms[name]) ~= "table" then return nil end + for i = 1, M.REMAP_MAX do + local row = uniforms[name][i] + if type(row) ~= "table" then return nil end + result[name][i] = {} + for c = 1, 3 do + local n = row[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 1 then return nil end + result[name][i][c] = n + end + end + end + return result +end +function M.useRemapUniforms(uniforms) + local g = graphics() + if not (g and g.setShader) then return false end + local sh = M.remapShader() + uniforms = copyUniforms(uniforms) + if not (sh and uniforms) then return false end + local previous = remapSent[sh] + if not pcall(uploadRemap, sh, uniforms) then + if previous then pcall(uploadRemap, sh, previous) end + failed.remap, shaders.remap = true, nil + return false + end + g.setShader(sh) + return true, uniforms.ambiguous +end +function M.useRemap(bgPalettes, byte, objPalettes, ramp, objRamped) + return M.useRemapUniforms(M.remapUniforms(bgPalettes, byte, objPalettes, ramp, objRamped)) +end + local function scoped(colors, body, apply) local g = graphics() local previous = g and g.getShader and g.getShader() or nil -- Restoring only the shader object is insufficient: nested calls reuse it -- and change its uniforms. Save the palette values as well as its identity. local previousColors = previous and sent[previous] + local previousRemap = previous and remapSent[previous] local ok, applied = pcall(function() local active = apply(colors) body() @@ -113,6 +252,7 @@ local function scoped(colors, body, apply) end) local restored, why = pcall(function() if previousColors then upload(previous, previousColors) end + if previousRemap then uploadRemap(previous, previousRemap) end if g and g.setShader then g.setShader(previous) end end) if not ok then error(applied, 0) end diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index fd6aee72e..ad57bc3ba 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -1129,6 +1129,40 @@ function PaletteFX.shadeMap() return shadeMap end +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Public byte-register access and native frame drawing share one state. +-- Renderer.beginFrame's existing reset therefore also clears a mod's BGP; +-- a byte cannot silently survive a menu, map or battle frame. +PaletteFX.BGP_IDENTITY = 0xe4 +function PaletteFX.bgpShades(byte) + byte = byte or PaletteFX.BGP_IDENTITY + local out = {} + for i = 0, 3 do out[i + 1] = math.floor(byte / 4 ^ i) % 4 end + return out +end +function PaletteFX.bgp() + if not shadeMap then return nil end + local byte = 0 + for i = 0, 3 do byte = byte + shadeMap[i] * 4 ^ i end + return byte ~= PaletteFX.BGP_IDENTITY and byte or nil +end +function PaletteFX.setBgp(byte) + local previous = PaletteFX.bgp() + if byte == nil or byte == PaletteFX.BGP_IDENTITY then + PaletteFX.setShadeMap(nil) + else + local shades = PaletteFX.bgpShades(byte) + PaletteFX.setShadeMap({[0]=shades[1], shades[2], shades[3], shades[4]}) + end + return previous +end +function PaletteFX.remap(colors, byte) + if not colors or not byte or byte == PaletteFX.BGP_IDENTITY then return colors end + local shades, out = PaletteFX.bgpShades(byte), {} + for i = 1, 4 do out[i] = colors[shades[i] + 1] or colors[4] end + return out +end + function PaletteFX.setMode(mode) local prev = PaletteFX.mode local ok = false @@ -1217,7 +1251,7 @@ function PaletteFX.paperShade(data) return c[1] / 255, c[2] / 255, c[3] / 255 end -function PaletteFX.effectiveColors(c) +function PaletteFX.displayColors(c) if not c then return nil end local mode = PaletteFX.mode or "gbc" local out = c @@ -1233,12 +1267,10 @@ function PaletteFX.effectiveColors(c) elseif mode == "gbc_inv" then out = PaletteFX.permute(c, INV_MAP) end - -- The shade map goes on LAST: rBGP is a hardware register write, so it - -- composes on top of whatever colors the display mode settled on -- a dark - -- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too - -- (#322). permute() is the identity (and returns `out` itself, which the - -- mod-graphics parity check leans on) while nothing has armed one. - return PaletteFX.permute(out, shadeMap) + return out +end +function PaletteFX.effectiveColors(c) + return PaletteFX.permute(PaletteFX.displayColors(c), shadeMap) end -- send a 4-color (0-255 RGB) palette to the shade-remap shader, after diff --git a/tests/engine/gen2_palette_draw.lua b/tests/engine/gen2_palette_draw.lua index 76de1532e..c8aeef1d8 100644 --- a/tests/engine/gen2_palette_draw.lua +++ b/tests/engine/gen2_palette_draw.lua @@ -70,7 +70,7 @@ for _,game in ipairs({'red','gold','silver','crystal'})do T.eq(G.mode,display,game..' '..mode..' public mode follows actual native resolution') local trueColor=true T.eq(not(trueColor and G.mode=='gbc'),display~='gbc',game..' '..mode..' unchanged evolution colour gate preserves true-colour art') - T.same(G.resolve(A),resolved,game..' '..mode..' shares actual native colour/register resolution') + T.same(G.resolve(A),P.displayColors(A),game..' '..mode..' resolves mode without consuming the register') G.with(A,function()T.same(active.values,normalized(resolved),game..' '..mode..' actual use shares resolved colours')end) G.withRaw(A,function()T.same(active.values,normalized(A),game..' '..mode..' raw use bypasses display/register state')end) T.same(G.color(A,2),resolved[2],game..' '..mode..' solid colour shares same native row') diff --git a/tests/engine/gen2_palette_register.lua b/tests/engine/gen2_palette_register.lua new file mode 100644 index 000000000..bee18ef2d --- /dev/null +++ b/tests/engine/gen2_palette_register.lua @@ -0,0 +1,97 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local G=require('src.render.GbcPalette') +local P=require('src.render.PaletteFX') +local V=require('src.core.GameVersion') +local A={{251,241,239},{211,37,53},{17,181,89},{29,31,157}} +local B={{233,227,223},{13,67,197},{163,41,131},{3,7,11}} +local R={{229,199,167},{179,127,79},{109,61,23},{47,19,5}} +local active,fail +local function compiler(code) + return {code=code,values={},send=function(self,name,...) + if fail==name then fail=nil;error('driver rejected uniform')end + local args={...};local out={} + for i,v in ipairs(args)do out[i]=type(v)=='table'and{unpack(v)}or v end + self.values[name]=out + end} +end +love.graphics={newShader=compiler,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G.BGP_IDENTITY,0xe4,'public identity byte') +T.same(G.bgpShades(),{0,1,2,3},'omitted byte is identity') +for _,edition in ipairs({'red','gold','silver','crystal'})do + V.set(edition);P.setMode('gbc');P.setShadeMap(nil) + for byte=0,255 do + local fields,expected={},{} + for i=0,3 do fields[i+1]=math.floor(byte/4^i)%4;expected[i+1]=A[fields[i+1]+1]end + local label=edition..' byte '..byte + T.same(G.bgpShades(byte),fields,label..' reads all two-bit fields') + T.same(G.remap(A,byte),expected,label..' explicit palette permutation') + local before=G.bgp + T.eq(G.setBgp(byte),before,label..' returns actual previous register') + T.eq(G.bgp,byte~=0xe4 and byte or nil,label..' public byte reflects native state') + T.same(P.effectiveColors(A),expected,label..' native renderer receives permutation') + T.eq(G.resolve(A),A,label..' inspecting colours does not consume BGP') + T.same(G.color(A,2),expected[2],label..' solid colours consume it exactly once') + G.use(A) + for i=1,4 do T.same(active.values['pal'..i-1][1],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255},label..' indexed draw row '..i)end + local native=compiler('native');P.sendColors(native,A) + for i=1,4 do T.same(native.values['c'..i-1][1],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255},label..' native uniform row '..i)end + end + P.setShadeMap({[0]=2,3,0,1});T.eq(G.bgp,0x4e,edition..' native register write visible publicly') + G.bgp=0xf9;T.eq(P.bgp(),0xf9,edition..' legacy property writes native register') + G.bgp=nil;T.eq(P.shadeMap(),nil,edition..' legacy nil clears native state') + G.setBgp(0);P.setShadeMap(nil);T.eq(G.bgp,nil,edition..' native frame reset clears public register') +end +T.eq(G.remap(A,nil),A,'nil remap retains identity') +T.eq(G.remap(A,0xe4),A,'identity remap retains table identity') +T.eq(G.remap(nil,0xf9),nil,'missing palette stays missing') +V.set('crystal');P.setShadeMap(nil) +local src,dst,count,ambiguous=G.remapTable({A},0xf9,{B}) +T.eq(count,8,'BG and OBJ rows both recognized') +T.eq(ambiguous,0,'disjoint palette mapping exact') +for i=1,4 do T.same(src[i],A[i],'BG source row');T.same(dst[i],A[({2,3,4,4})[i]],'BG byte applies own palette');T.same(dst[i+4],B[i],'OBJ row stays unchanged')end +T.eq(#src,64,'source bounded and padded');T.eq(#dst,64,'destination bounded and padded') +T.neq(src[9],src[1],'padding owns its row');T.same(src[9],src[1],'padding repeats first colour') +local _,collisionDst,_,collisions=G.remapTable({A},0xff,{A}) +T.eq(collisions,3,'overlapping BG/OBJ collision count is explicit') +T.same(collisionDst[1],A[4],'first BG interpretation wins') +local rampSrc,rampDst,rampCount,rampAmbiguous=G.remapTable({A},0xf9,{B},R,{true}) +T.eq(rampCount,12,'forced ramp included as already drawn source') +T.eq(rampAmbiguous,0,'disjoint forced ramp exact') +T.same(rampDst[1],R[2],'forced BG takes explicit byte') +T.same(rampDst[9],R[1],'ramped OBJ uses unpermuted forced ramp') +local _,unramped=G.remapTable({A},0xf9,{B},R) +T.same(unramped[9],B[1],'unramped OBJ retains original colour') +G.setBgp(0xff) +local u=G.remapUniforms({A},0xf9,{B}) +T.same(u.src[1],{A[1][1]/255,A[1][2]/255,A[1][3]/255},'explicit backward byte ignores standing forward register') +T.same(u.dst[1],{A[2][1]/255,A[2][2]/255,A[2][3]/255},'backward byte consumed once') +G.setBgp(nil) +local ok,n=G.useRemapUniforms(u);T.check(ok,'remap shader applies');T.eq(n,0,'successful apply reports ambiguity') +local sh=active;T.eq(sh,G.remapShader(),'actual remap shader active') +T.check(sh.code:find('vec4(rgb, pixel.a) * tint',1,true)~=nil,'backward shader retains alpha and draw tint') +T.same(sh.values.remapSrc,u.src,'all normalized source entries uploaded') +T.same(sh.values.remapDst,u.dst,'all normalized destination entries uploaded') +T.same(sh.values.remapCount,{8},'bounded live count uploaded') +local first=u.src[1][1];u.src[1][1]=0 +G.withRaw(A,function()G.useRemap({B},0xff)end) +T.eq(active,sh,'nested draw restores remap shader') +T.eq(sh.values.remapSrc[1][1],first,'nested draw restores detached original remap uniforms') +local did,why=pcall(function()G.withRaw(A,function()G.useRemap({B},0xff);error('draw failure',0)end)end) +T.check(not did and why=='draw failure','callback failure propagates') +T.eq(active,sh,'error restores remap shader');T.eq(sh.values.remapSrc[1][1],first,'error restores remap uniforms') +T.eq(G.useRemap({}),false,'empty palette leaves shader alone');T.eq(active,sh,'empty palette keeps prior shader') +local rows={};for p=1,20 do rows[p]={{p,1,1},{p,2,2},{p,3,3},{p,4,4}}end +local hugeSrc,hugeDst,hugeCount=G.remapTable(rows,0xff) +T.eq(hugeCount,64,'overfull palette tables bounded');T.eq(#hugeSrc,64,'source cannot overflow shader');T.eq(#hugeDst,64,'destination cannot overflow shader') +T.eq(G.remapUniforms({false,{},{{1,2,3}}},0xf9),nil,'malformed palette rows decline safely') +T.eq(G.useRemapUniforms({count=65,src={},dst={}}),false,'oversized borrowed uniforms rejected') +fail='remapDst';T.eq(G.useRemap({B},0xff),false,'driver upload failure declines effect') +T.eq(active,sh,'driver failure keeps previous shader');T.eq(sh.values.remapSrc[1][1],first,'partial driver failure restores previous uniforms') +T.eq(G.remapShader(),nil,'failed remap shader disabled for current compiler') +love.graphics.newShader=function(code)return compiler(code)end +T.check(G.useRemap({A},0xf9),'new compiler recovers from failed upload') +love.graphics={};T.eq(G.useRemap({A},0xf9),false,'headless remap declines effect') +T.finish('native palette byte register and composited BG/OBJ remapping') diff --git a/tests/gpu/gen2_palette_register.lua b/tests/gpu/gen2_palette_register.lua new file mode 100644 index 000000000..72814580b --- /dev/null +++ b/tests/gpu/gen2_palette_register.lua @@ -0,0 +1,89 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- ROM-free pixel verification in a separate LÖVE host on each backend. +local M = {} +function M.run() + require('src.core.ModCompat').install() + local G = require('src.render.GbcPalette') + local P = require('src.render.PaletteFX') + local V = require('src.core.GameVersion') + local version, mode, map = V.get(), P.mode, P.shadeMap() + local g = love.graphics + local A={{251,241,239},{211,37,53},{17,181,89},{29,31,157}} + local B={{233,227,223},{13,67,197},{163,41,131},{3,7,11}} + local C={107,149,207} + local shades = love.image.newImageData(4,1,'rgba8') + local rgba = love.image.newImageData(9,1,'rgba8') + for i=0,3 do + local n=1-i/3;shades:setPixel(i,0,n,n,n,1-i/5) + for p,colors in ipairs({A,B}) do + local c=colors[i+1];rgba:setPixel((p-1)*4+i,0,c[1]/255,c[2]/255,c[3]/255,1-i/5) + end + end + rgba:setPixel(8,0,C[1]/255,C[2]/255,C[3]/255,0.4) + local indexed, colored = g.newImage(shades), g.newImage(rgba) + indexed:setFilter('nearest','nearest');colored:setFilter('nearest','nearest') + local canvas=g.newCanvas(17,257,{format='rgba8',dpiscale=1}) + local checks=0 + local function check(ok,why) checks=checks+1;assert(ok,why) end + g.push('all') + local ok,why=xpcall(function() + V.set('crystal');P.setMode('gbc') + g.setCanvas(canvas);g.clear(0,0,0,0);g.origin() + g.setBlendMode('replace','premultiplied');g.setColor(0.5,0.75,1,0.8) + local native = assert(P.shader(),'native palette shader unavailable') + for byte=0,255 do + G.setBgp(byte) + check(G.use(A),'forward shader unavailable') + g.draw(indexed,0,byte) + -- Native PaletteFX owns the same register and the real optimized + -- userdata upload path, rather than just a fake public shader object. + P.sendColors(native,A);g.setShader(native);g.draw(indexed,4,byte) + -- The explicit backward byte is independent of the standing register. + G.setBgp(0xff) + local applied,ambiguous=G.useRemap({A},byte,{B}) + check(applied and ambiguous==0,'backward shader unavailable or ambiguous') + g.draw(colored,8,byte) + end + G.setBgp(nil) + local display=P.displayColors + P.displayColors=function()return B end + local changed,changeError=pcall(function() + if P._beginColorBatch then check(P._beginColorBatch(native)==nil,'overridden display resolver entered a trusted native batch') end + P.sendColors(native,A);g.setShader(native);g.draw(indexed,0,256) + end) + P.displayColors=display + if not changed then error(changeError,0) end + g.setCanvas();g.setShader() + local pixels=g.readbackTexture and g.readbackTexture(canvas) or canvas:newImageData() + for byte=0,255 do + for x=0,16 do + local color,alpha + if x<8 then + local shade=x%4;local field=math.floor(byte/4^shade)%4 + color,alpha=A[field+1],1-shade/5 + elseif x<12 then + local shade=x-8;local field=math.floor(byte/4^shade)%4 + color,alpha=A[field+1],1-shade/5 + elseif x<16 then color,alpha=B[x-11],1-(x-12)/5 + else color,alpha=C,0.4 end + local wanted={color[1]/255*0.5,color[2]/255*0.75,color[3]/255,alpha*0.8} + -- PaletteFX's native final-zone shader deliberately ignores the + -- draw tint. The public indexed/remap shaders compose with it. + if x>=4 and x<8 then wanted={color[1]/255,color[2]/255,color[3]/255,alpha} end + local actual={pixels:getPixel(x,byte)} + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255, + 'register '..byte..' pixel '..x..' channel '..c..' got '..actual[c]..' expected '..wanted[c])end + end + end + for x=0,3 do + local actual={pixels:getPixel(x,256)} + local wanted={B[x+1][1]/255,B[x+1][2]/255,B[x+1][3]/255,1-x/5} + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255,'native upload ignored replaced display resolver')end + end + end,debug.traceback) + g.pop();P.setShadeMap(map);P.setMode(mode);V.set(version) + indexed:release();colored:release();shades:release();rgba:release() + if not ok then canvas:release();error(why,0) end + return {passed=true,checks=checks,scope='All 256 BGP bytes: native and public indexed draws, composited BG permutation, OBJ preservation, unrelated RGB preservation, alpha and tint'},canvas +end +return M From ab7c7508f3bb43c7a6156433791c1aab59088690 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:28:25 +0100 Subject: [PATCH 054/187] Apply native Gen2 display selection and complete-frame palette presentation --- docs/NATIVE-GEN2-DISPLAY.md | 64 +++++++++++ docs/NATIVE-GEN2-PALETTE-REGISTER.md | 12 +- src/battle/BattleState.lua | 30 ++++- src/core/Game.lua | 8 +- src/render/Gen2PaletteDraw.lua | 25 ++++- src/render/Gen2Presentation.lua | 79 +++++++++++++ src/render/Palette.lua | 136 +++++++++++++++++++++++ src/render/PaletteFX.lua | 159 ++++++++++++++++++++------- src/render/Renderer.lua | 51 ++++++--- src/render/SpriteRenderer.lua | 5 + src/render/TileRenderer.lua | 11 +- src/ui/OptionsMenu.lua | 15 ++- tests/engine/gen2_display_loader.lua | 45 ++++++++ tests/engine/gen2_display_modes.lua | 138 +++++++++++++++++++++++ tests/engine/gen2_palette_draw.lua | 2 +- tests/engine/palette_library.lua | 50 +++++++++ tests/gpu/gen2_display_modes.lua | 153 ++++++++++++++++++++++++++ 17 files changed, 905 insertions(+), 78 deletions(-) create mode 100644 docs/NATIVE-GEN2-DISPLAY.md create mode 100644 src/render/Gen2Presentation.lua create mode 100644 src/render/Palette.lua create mode 100644 tests/engine/gen2_display_loader.lua create mode 100644 tests/engine/gen2_display_modes.lua create mode 100644 tests/engine/palette_library.lua create mode 100644 tests/gpu/gen2_display_modes.lua diff --git a/docs/NATIVE-GEN2-DISPLAY.md b/docs/NATIVE-GEN2-DISPLAY.md new file mode 100644 index 000000000..05e36f705 --- /dev/null +++ b/docs/NATIVE-GEN2-DISPLAY.md @@ -0,0 +1,64 @@ +# Native Gen2 display selection + +Gen2 display selection belongs to `PaletteFX`, the same owner used by the +native tile, sprite and battle renderers. `GbcPalette` exposes the Gen1Recomp +public vocabulary over that state: `gbc`, `dmg`, `classic`, and `custom`. +The generic native ID for DMG remains `og`, preserving existing option files. + +GBC uses the cartridge's indexed palettes. DMG retains the four original +indexed shades as grayscale. Classic uses that same grayscale intermediate +and maps the completed frame through the green ramp once. A custom ramp +replaces indexed palette rows; already-coloured art keeps its existing +true-colour contract. The unrelated Gen1 colourization packs and Gen3 +display behaviour retain their existing paths. + +`setMode`, `cycle(delta)`, `setCustomRamp`, `applyOptions`, `modeLabel`, and +`presentColors` are available through `GbcPalette`. The mode, custom ramp and +byte BGP all share native state. Raw indexed drawing bypasses display/BGP +selection. A custom ramp takes priority over the selected indexed mode, as +in the reference protocol; Classic's final presentation remains a separate +operation. Missing custom ramps fall back to grayscale. + +`PaletteFX.setDisplay(mode, ramp)` applies a selection atomically. It invalidates +tile and animation textures, OBJ images, battle images and cached maps, then +reloads the entered map. The shared `Assets.register` invalidation bus also +notifies external texture producers, including repeated ramps with the same mode +ID. Retained battle image references resolve through their +original palette and crop on the next draw. Reapplying an edited ramp table +also updates its key and invalidates dependent caches. Transient BGP writes do +not enter static atlas cache keys. Mono/custom world bakes retain cave darkness; +battle palette permutations and blackout apply after display selection. + +Native Gen2 options and hotkey 2 use the GBC → DMG → Classic ladder, with a +backward step supported. Cycling clears a selected custom ramp and saved +palette ID. Native `options.colors` and legacy `options.color` record the same +selection. Applying legacy mode plus `options.palette` performs one rebuild. + +`src.render.Palette` supplies the public palette ID/library operations. Mono IDs +are `m:rrggbb`; named IDs are `p:group/name`. An optional authored +`data.gb_palettes` module supplies named entries; no palette catalogue is +invented or extracted by this library. Categories, labels, lookup, swatches, +ramp caching and invalidation use the reference vocabulary. + +## Completed-frame zones and composition + +For native Gen2 gameplay, `render.zones` receives the final display zones once, +after native drawing has settled its palettes. Classic starts with one green +whole-frame zone; other modes start with nil. Consumers can replace, remove or +extend those zones. Each dispatch gets its own list. This final pass uses raw +palette destinations so Classic cannot resolve its own green ramp back to gray. + +The native Gen2 compositor first combines world and UI into one scene. Its +window-menu callback includes a window-resolution drawer when one is supplied; +this offer does not add the separate native window-menu classes or a palette-picker UI. `render.compose` then receives that completed texture in +`sceneCanvas`, `worldCanvas` and `uiCanvas`, with `generation = 2`, the native +renderer and an explicit `game` owner. The render target is different from the +source texture. A consumer taking composition ownership also owns presentation +of the supplied final zones; `Renderer:blitZones` provides the raw zone blit. +Otherwise the engine applies final zones before present/output effects. HUD and +touch controls retain their screen-space placement outside this game scene. + +This is display work toward Gen1 parity. It does not establish complete UI +raster parity, a full unchanged-mod playthrough, save/link acceptance, hardware +performance or full engine parity. Existing upstream/Gen1 licences and the +[Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md) remain applicable. diff --git a/docs/NATIVE-GEN2-PALETTE-REGISTER.md b/docs/NATIVE-GEN2-PALETTE-REGISTER.md index 10d840c11..9f5188eed 100644 --- a/docs/NATIVE-GEN2-PALETTE-REGISTER.md +++ b/docs/NATIVE-GEN2-PALETTE-REGISTER.md @@ -19,8 +19,8 @@ native frame reset clears it as well. `resolve(colors)` selects the display palette without applying the register. `color`, `use`, `with` and keyed draws apply the current register once. `useRaw` and `withRaw` bypass display selection and the register. Native -`PaletteFX.sendColors` consumes the same state. The separate native palette-upload optimization, when -present, consumes that state as well. +`PaletteFX.sendColors` consumes the same state. The separate palette-upload +optimization uses this owner when present. ## Already-coloured frames @@ -57,10 +57,10 @@ through public and native shaders, including the optimized native upload path, OBJ preservation, unrelated RGB, alpha and tint. Run `run()` from an isolated LÖVE host separately on each backend. It requires no cartridge or private mod. -This change does not implement full Gen1 display parity. Native Gen2 still needs -the DMG/Classic/custom display selections, their cached tile/sprite/battle -invalidation and option persistence. Those require the real rendering owners; -adding setters that only change a public mode string would not implement them. +This register change alone does not establish full Gen1 display parity. See +`NATIVE-GEN2-DISPLAY.md` for the native display-selection and cache/presentation +owners. Full unchanged-mod story/save/link acceptance and wider engine parity +remain separate requirements. The public method protocol follows the Gen1Recomp reference interface. Existing Gen1Recomp and upstream credits and licences remain applicable; see `NOTICE.md`. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 297e84c74..ec68eed25 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -639,7 +639,16 @@ local imageMeta = setmetatable({}, WEAK_KEYS) local function getImage(path, pal, trueColor, crop) if not path then return nil end if trueColor then pal = nil end + local sourcePal, displayKey = pal + if GameVersion.isGen2() and not trueColor and pal then + local display = require("src.render.PaletteFX") + displayKey = display.displayKey() + if not pal.displayResolved then + pal = {name=pal.name, colors=display.displayColors(pal.colors), displayResolved=true} + end + end local key = pal and (path .. "#" .. pal.name) or path + if displayKey then key = key .. "#display:" .. displayKey end if crop then key = key .. "@" .. crop.index end if not imageCache[key] then local img, pad, padL = nil, 0, 0 @@ -693,7 +702,7 @@ local function getImage(path, pal, trueColor, crop) -- crop rides along so fadeImage / grayImage / blackImage re-bake the -- SAME cell of the strip, not the whole strip imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil, - crop = crop } + crop = crop, sourcePal = sourcePal, displayKey = displayKey } end return imageCache[key] end @@ -820,7 +829,7 @@ local function fadeImage(img, bgp) local name = (meta.pal and meta.pal.name or "GB") .. "&" .. bgp[0] .. bgp[1] .. bgp[2] .. bgp[3] return getImage(meta.path, - { name = name, colors = PaletteFX.permute(base, bgp) }, + { name = name, colors = PaletteFX.permute(base, bgp), displayResolved=GameVersion.isGen2() }, nil, meta.crop) end @@ -848,8 +857,12 @@ local function blackImage(data, img) local pack = PaletteFX.pack(data) local colors = pack and pack.palettes and pack.palettes.BLACK if not colors then return img end + if GameVersion.isGen2() then + local ramp = PaletteFX.displayColors(colors) + colors = {ramp[4],ramp[4],ramp[4],ramp[4]} + end local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK" - return getImage(meta.path, { name = name, colors = colors }, nil, meta.crop) + return getImage(meta.path, { name = name, colors = colors, displayResolved=GameVersion.isGen2() }, nil, meta.crop) or img end @@ -864,6 +877,15 @@ end -- the image a battler pic actually draws with this frame function BattleState:picImage(img) local PaletteFX = require("src.render.PaletteFX") + if GameVersion.isGen2() then + local meta = imageMeta[img] + -- Battlers retain image references across a live option change. Dropping + -- imageCache alone does not replace those pictures or animation cells. + if meta and not meta.trueColor and meta.sourcePal + and meta.displayKey ~= PaletteFX.displayKey() then + img = getImage(meta.path, meta.sourcePal, nil, meta.crop) or img + end + end -- #207: OG / OG INV / CLASSIC are forced-mono display modes. A battle that -- exposes no SGB zones (sgbPalettes() == nil) has a whole-screen GRAYS zone -- invented by PaletteFX.ensureZones, so Renderer:endFrame re-thresholds the @@ -878,7 +900,7 @@ function BattleState:picImage(img) -- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync. local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" or PaletteFX.mode == "classic" - if self.grayPics or mono then return grayImage(img) end + if self.grayPics or (mono and not GameVersion.isGen2()) then return grayImage(img) end -- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go -- dark with the HP bars while the blackout text is up (#292). The intro -- silhouette slide (SlidePlayerAndEnemySilhouettesOnScreen) darkens the diff --git a/src/core/Game.lua b/src/core/Game.lua index 0ebe75e81..60bd2784c 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1171,7 +1171,11 @@ function Game:_draw() end -- 14's render.zones: weather/lighting overlays and custom colorization -- recolor or add zones before the blit - if ModRuntime.wantsHook("render.zones") then + if require("src.core.GameVersion").isGen2() then + Renderer.gen2FrameManaged = true + Renderer.gen2FrameGame = self + Renderer.gen2FrameZones = require("src.render.Gen2Presentation").zones(self) + elseif ModRuntime.wantsHook("render.zones") then zones = ModRuntime.call("render.zones", sameZones, self, zones) end -- Keyed to whether the map actually DREW, not to whether it is the clear's @@ -1408,7 +1412,7 @@ function Game:keypressed(key) or ow.engaging or ow.emote))) if not busy then local PaletteFX = require("src.render.PaletteFX") - self.save.options.colors = PaletteFX.cycleMode() + PaletteFX.writeColorOption(self.save.options, PaletteFX.cycleMode()) self:writeOptions() end return diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua index f84798c13..481165c12 100644 --- a/src/render/Gen2PaletteDraw.lua +++ b/src/render/Gen2PaletteDraw.lua @@ -1,21 +1,23 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md -- Indexed image drawing, separate from the LCD post-process in GBCFX. -- Palette resolution uses the native display/register state in PaletteFX. local Palettes = require("src.render.PaletteFX") local Version = require("src.core.GameVersion") local M = {} --- Consumers use this view to leave already-coloured pictures untouched. --- Gen2 currently resolves native ROM colours regardless of the Gen1 mode. --- Do not invent a second mutable display setting for the external renderer. +-- Display state belongs to the same owner as the native map/OBJ/pic bakes. setmetatable(M, {__index = function(_, key) if key == "mode" then - if Version.isGen2() then return "gbc" end + if Version.isGen2() and Palettes.mode == "custom" then return "custom" end if Palettes.mode == "og" or Palettes.mode == "og_inv" then return "dmg" end return Palettes.mode == "classic" and "classic" or "gbc" end if key == "bgp" then return Palettes.bgp() end + if key == "customRamp" then return Palettes.customRamp() end end, __newindex = function(self, key, value) if key == "bgp" then Palettes.setBgp(value) + elseif key == "mode" then M.setMode(value) + elseif key == "customRamp" then Palettes.setCustomRamp(value) else rawset(self, key, value) end end}) local compiler, shaders, failed = nil, {}, {} @@ -106,6 +108,19 @@ M.BGP_IDENTITY = Palettes.BGP_IDENTITY M.bgpShades = Palettes.bgpShades M.remap = Palettes.remap M.setBgp = Palettes.setBgp +M.MODES = {"gbc", "dmg", "classic"} +M.MODE_LABELS = {gbc="GEN 2", dmg="DMG", classic="CLASSIC", custom="CUSTOM"} +M.CUSTOM_MODE = "custom" +M.setCustomRamp = Palettes.setCustomRamp +M.presentColors = Palettes.presentColors +function M.setMode(mode) + if mode~="gbc" and mode~="dmg" and mode~="classic" and mode~="custom" then mode="gbc" end + Palettes.setMode(mode) + return M.mode +end +function M.cycle(delta) Palettes.cycleMode(delta); return M.mode end +function M.applyOptions(opts) Palettes.applyOptions(opts); return M.mode end +function M.modeLabel(mode) return M.MODE_LABELS[mode or M.mode] or "GEN 2" end function M.resolve(colors) return Palettes.displayColors(colors) end function M.color(colors, index) local palette = Palettes.effectiveColors(colors or Palettes.GRAYS) diff --git a/src/render/Gen2Presentation.lua b/src/render/Gen2Presentation.lua new file mode 100644 index 000000000..017a877da --- /dev/null +++ b/src/render/Gen2Presentation.lua @@ -0,0 +1,79 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Gen2 display zones belong to the completed colour frame, after indexed +-- native BG/OBJ/UI bakes and before present/output effects. In Classic those +-- bakes are grayscale and this pass supplies the green ramp exactly once. +local P = require("src.render.PaletteFX") +local Gbc = require("src.render.Gen2PaletteDraw") +local Runtime = require("src.mods.Runtime") +local M = {} +local target +local function identity(_, zones) return zones end +function M.zones(game) + local colors = P.presentColors() + local zones = colors and {{x=0,y=0,w=160,h=144,colors=colors}} or nil + if Runtime.wantsHook("render.zones") then + zones = Runtime.call("render.zones", identity, game, zones) + end + return zones +end +local function finite(n) return type(n)=="number" and n==n and math.abs(n)x1 and y2>y1 then + g.setScissor(x1,y1,x2-x1,y2-y1) + if zone.colors==false then g.setShader();g.draw(source,0,0) + else Gbc.withRaw(zone.colors,function()g.draw(source,0,0)end) end + end + end + end,debug.traceback) + g.pop() + if not ok then error(why,0) end + end) +end +function M.apply(source, zones, w, h) + if not (type(zones)=="table" and zones[1]) then return source end + local g=love.graphics + if not (g.newCanvas and Gbc.available()) then return source end + local dpi=source.getDPIScale and source:getDPIScale() or 1 + if not target or target:getWidth()~=w or target:getHeight()~=h + or (target.getDPIScale and target:getDPIScale()~=dpi) then + if target then target:release() end + target=g.newCanvas(w,h,{format="normal",dpiscale=dpi,msaa=0}) + target:setFilter("nearest","nearest") + end + local previous={g.getCanvas()} + Gbc.withRaw(nil,function() + g.push("all") + local ok,why=xpcall(function() + g.setCanvas(target);g.origin();g.setShader();g.setScissor() + g.setColorMask(true,true,true,true);g.setStencilTest();g.setDepthMode() + g.setBlendMode("alpha","premultiplied");g.clear(0,0,0,1) + M.blit(source,zones,w,h) + end,debug.traceback) + g.setCanvas(unpack(previous));g.pop() + if not ok then error(why,0) end + end) + return target +end +function M.reset() + if target then target:release();target=nil end +end +return M diff --git a/src/render/Palette.lua b/src/render/Palette.lua new file mode 100644 index 000000000..6982156b2 --- /dev/null +++ b/src/render/Palette.lua @@ -0,0 +1,136 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Palette IDs and category vocabulary follow Gen1Recomp's public protocol. +-- An optional authored data.gb_palettes library supplies named ramps; this +-- module contains no cartridge palette catalogue or private renderer data. +local P = {} +P.MONO_COLOURS = { + {"GREEN",155,188,15}, {"LIME",168,216,56}, {"OLIVE",166,172,132}, + {"AMBER",255,176,0}, {"ORANGE",255,128,48}, {"RED",255,80,80}, + {"ROSE",255,144,176}, {"MAGENTA",240,96,208}, {"PURPLE",176,112,240}, + {"INDIGO",128,128,240}, {"BLUE",80,160,255}, {"CYAN",64,224,224}, + {"TEAL",64,192,160}, {"MINT",144,224,176}, {"SAND",224,200,144}, + {"BROWN",192,144,80}, {"WHITE",255,255,255}, {"SILVER",208,216,224}, +} +P.GREY_CHROMA, P.HUE_ARC = 12, 50 +P.CATEGORIES = {{key="full",label="FULL COLOUR"},{key="single",label="SINGLE COLOUR"},{key="grey",label="GREYSCALE"}} +local library, index, buckets, lastId, lastRamp +function P.packs() + if library == nil then + local ok, value = pcall(require, "data.gb_palettes") + library = ok and type(value) == "table" and value or false + end + return library or {} +end +function P.packId(group, name) return "p:" .. group .. "/" .. name end +function P.monoId(r,g,b) return ("m:%02x%02x%02x"):format(r,g,b) end +function P.monoRamp(r,g,b) + local out = {} + for i, scale in ipairs({1,0.72,0.38,0.12}) do + out[i] = {math.floor(r*scale+0.5),math.floor(g*scale+0.5),math.floor(b*scale+0.5)} + end + return out +end +function P.unhex(text) + if type(text) ~= "string" or #text ~= 24 or text:find("[^%x]") then return nil end + local out = {} + for i=1,4 do + out[i] = {} + for c=1,3 do local at=(i-1)*6+(c-1)*2+1;out[i][c]=tonumber(text:sub(at,at+1),16) end + end + return out +end +function P.hueChroma(color) + local r,g,b=color[1] or 0,color[2] or 0,color[3] or 0 + local hi,lo=math.max(r,g,b),math.min(r,g,b) + local c=hi-lo + if c<=0 then return 0,0 end + local h + if hi==r then h=((g-b)/c)%6 elseif hi==g then h=(b-r)/c+2 else h=(r-g)/c+4 end + return h*60,c +end +function P.hueArc(hues) + local span=360 + for _,start in ipairs(hues) do + local width=0 + for _,hue in ipairs(hues) do width=math.max(width,(hue-start)%360) end + span=math.min(span,width) + end + return span +end +function P.categoryOf(ramp) + if type(ramp)~="table" or not ramp[4] then return nil end + local hues={} + for i=1,4 do + local h,c=P.hueChroma(ramp[i]) + if c>P.GREY_CHROMA then hues[#hues+1]=h end + end + if #hues==0 then return "grey" end + return P.hueArc(hues)<=P.HUE_ARC and "single" or "full" +end +local function ensureIndex() + if index then return end + index,buckets={},{full={},single={},grey={}} + for _,pack in ipairs(P.packs()) do + for _,entry in ipairs(pack.palettes or {}) do + if type(entry[1])=="string" then + local id=P.packId(pack.name,entry[1]);index[id]=entry + local ramp=P.unhex(entry[2]);local category=ramp and P.categoryOf(ramp) + if category then + local bucket=buckets[category] + bucket[#bucket+1]={name=entry[1],pack=pack.name,id=id} + end + end + end + end +end +function P.findPack(group,name) ensureIndex();return index[P.packId(group,name)] end +function P.category(key) + ensureIndex() + for _,category in ipairs(P.CATEGORIES) do + if category.key==key then return {key=key,name=category.label,palettes=buckets[key]} end + end +end +function P.categories() + local out={} + for _,category in ipairs(P.CATEGORIES) do out[#out+1]=P.category(category.key) end + return out +end +function P.locate(id) + if type(id)~="string" or id=="" then return nil end + ensureIndex() + for _,category in ipairs(P.CATEGORIES) do + for i,palette in ipairs(buckets[category.key]) do if palette.id==id then return category.key,i end end + end +end +function P.label(id) + if type(id)~="string" or id=="" then return nil end + local hex=id:match("^m:(%x+)$") + if hex then + local r,g,b=tonumber(hex:sub(1,2),16),tonumber(hex:sub(3,4),16),tonumber(hex:sub(5,6),16) + for _,color in ipairs(P.MONO_COLOURS) do if r==color[2] and g==color[3] and b==color[4] then return color[1] end end + return "MONO" + end + return id:match("^p:.*/([^/]+)$") or id +end +function P.ramp(id) + if type(id)~="string" or id=="" then return nil end + if id==lastId then return lastRamp end + local ramp + local hex=id:match("^m:(%x%x%x%x%x%x)$") + if hex then ramp=P.monoRamp(tonumber(hex:sub(1,2),16),tonumber(hex:sub(3,4),16),tonumber(hex:sub(5,6),16)) + else + local group,name=id:match("^p:(.*)/([^/]+)$") + local entry=group and P.findPack(group,name) + local raw=entry and P.unhex(entry[2]) + if raw then ramp={raw[4],raw[3],raw[2],raw[1]} end + end + lastId,lastRamp=id,ramp + return ramp +end +function P.swatch(id) + local ramp=P.ramp(id) + if ramp then return {ramp[4],ramp[3],ramp[2],ramp[1]} end +end +function P.invalidate() index,buckets,lastId,lastRamp=nil,nil,nil,nil end +return P diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index ad57bc3ba..5cea28f01 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -44,6 +44,31 @@ PaletteFX.MODE_LABELS = { } PaletteFX.mode = "gbc" +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +PaletteFX.GEN2_MODES = {"gbc", "og", "classic"} +local customRamp, customKey = nil, "" +function PaletteFX.customRamp() return customRamp end +function PaletteFX.displayKey() return tostring(PaletteFX.mode) .. customKey end +function PaletteFX.colorModes() + return GameVersion.isGen2() and PaletteFX.GEN2_MODES or PaletteFX.MODES +end +local function rampKey(ramp) + if ramp == nil then return "" end + if type(ramp) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + local row = ramp[i] + if type(row) ~= "table" then return nil end + for c = 1, 3 do + local n = row[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + out[#out + 1] = ("%.17g"):format(n) + end + end + return "#ramp:" .. table.concat(out, ",") +end + -- ------- dark-cave state (wMapPalOffset) -- -- Unlike the per-frame shadeMap further down, this outlives a frame: ADVANCED @@ -421,6 +446,7 @@ local GEN2_SHADE_MODES = { } function PaletteFX.usesGen2ObjPal(mode) + if GameVersion.isGen2() then return true end mode = mode or PaletteFX.mode return not GEN2_SHADE_MODES[mode] end @@ -1129,7 +1155,8 @@ function PaletteFX.shadeMap() return shadeMap end --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md -- Public byte-register access and native frame drawing share one state. -- Renderer.beginFrame's existing reset therefore also clears a mod's BGP; -- a byte cannot silently survive a menu, map or battle frame. @@ -1163,54 +1190,89 @@ function PaletteFX.remap(colors, byte) return out end -function PaletteFX.setMode(mode) - local prev = PaletteFX.mode - local ok = false - for _, m in ipairs(PaletteFX.MODES) do - if m == mode then - PaletteFX.mode = mode - ok = true - break +local function invalidateDisplay() + -- Register native producers before the shared flush. External producers + -- subscribe to the same Assets.register bus; private cache calls alone + -- leave their atlases stale when a second ramp keeps the same mode ID. + for _, name in ipairs({"src.battle.BattleState","src.render.SpriteRenderer", + "src.render.TileRenderer","src.world.MapLoader"}) do + pcall(require, name) + end + require("src.render.Assets").invalidate() + pcall(function() + local Game = require("src.core.Game") + if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then + Game.overworld:reloadMap(Game.overworld.map.id, "colors") end + end) +end +local function normalizeMode(mode) + if GameVersion.isGen2() then + if mode == "dmg" then return "og" end + if mode == "custom" then return mode end end - if not ok then PaletteFX.mode = "gbc" end - -- battle pics and overworld sprites bake the active pack into ImageData; - -- drop those caches when the pack (or any COLORS mode) changes so the - -- next draw re-tints - if prev ~= PaletteFX.mode then - pcall(function() require("src.battle.BattleState").invalidate() end) - pcall(function() require("src.render.SpriteRenderer").invalidate() end) - -- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once - -- per loaded map, so a mode toggle needs every cached Map/TileRenderer - -- dropped and the currently-visible one rebuilt in place -- otherwise - -- the on-screen map keeps its stale (wrong-mode) atlas until the next - -- map transition happens to reload it. - pcall(function() - require("src.world.MapLoader").invalidateAll() - local Game = require("src.core.Game") - if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then - Game.overworld:reloadMap(Game.overworld.map.id, "colors") - end - end) + for _, m in ipairs(PaletteFX.MODES) do + if m == mode then return mode end end + return "gbc" +end +-- Set a complete selection atomically: applying persisted mode + ramp must +-- rebuild the entered map once, never briefly in the previous custom ramp. +function PaletteFX.setDisplay(mode, ramp) + local key = rampKey(ramp) + if key == nil then ramp, key = nil, "" end + mode = normalizeMode(mode) + local changed = mode ~= PaletteFX.mode or ramp ~= customRamp or key ~= customKey + PaletteFX.mode, customRamp, customKey = mode, ramp, key + if changed then invalidateDisplay() end + return mode +end +function PaletteFX.setMode(mode) + return PaletteFX.setDisplay(mode, customRamp) +end +function PaletteFX.setCustomRamp(ramp) + PaletteFX.setDisplay(PaletteFX.mode, ramp) + return customRamp end -function PaletteFX.cycleMode() +function PaletteFX.cycleMode(delta) local cur = PaletteFX.mode or "gbc" + if GameVersion.isGen2() and cur == "og_inv" then cur = "og" end + local modes = PaletteFX.colorModes() local idx = 1 - for i, m in ipairs(PaletteFX.MODES) do + for i, m in ipairs(modes) do if m == cur then idx = i; break end end - PaletteFX.setMode(PaletteFX.MODES[idx % #PaletteFX.MODES + 1]) + local nextMode = modes[(idx - 1 + (delta or 1)) % #modes + 1] + local ramp = customRamp + if GameVersion.isGen2() then ramp = nil end + PaletteFX.setDisplay(nextMode, ramp) return PaletteFX.mode end function PaletteFX.applyOptions(opts) - PaletteFX.setMode(opts and opts.colors or "gbc") + if GameVersion.isGen2() then + local ramp = opts and opts.palette and require("src.render.Palette").ramp(opts.palette) + return PaletteFX.setDisplay(opts and (opts.color or opts.colors) or "gbc", ramp) + end + return PaletteFX.setMode(opts and opts.colors or "gbc") +end +function PaletteFX.writeColorOption(opts, mode) + opts.colors = mode + if GameVersion.isGen2() then + opts.color = mode == "og" and "dmg" or mode + opts.palette = nil + end +end +function PaletteFX.presentColors() + if GameVersion.isGen2() and PaletteFX.mode == "classic" then return PaletteFX.CLASSIC end end function PaletteFX.modeLabel(mode) mode = mode or PaletteFX.mode + if GameVersion.isGen2() then + return ({gbc="GEN 2", og="DMG", dmg="DMG", classic="CLASSIC", custom="CUSTOM"})[mode] or "GEN 2" + end -- The GBC hardware mode wears the running game's name: OG RED / OG BLUE -- (boot-ROM auto-palette) or OG YELLOW (pokeyellow CGBBasePalettes). if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end @@ -1255,22 +1317,45 @@ function PaletteFX.displayColors(c) if not c then return nil end local mode = PaletteFX.mode or "gbc" local out = c - if gen2Native() then + if GameVersion.isGen3() then mode = "gbc" end - if mode == "og" then + if GameVersion.isGen2() and customRamp then return customRamp end + if mode == "og" or (GameVersion.isGen2() and mode == "custom") then out = PaletteFX.GRAYS elseif mode == "og_inv" then out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP) elseif mode == "classic" then - out = PaletteFX.CLASSIC + out = GameVersion.isGen2() and PaletteFX.GRAYS or PaletteFX.CLASSIC elseif mode == "gbc_inv" then out = PaletteFX.permute(c, INV_MAP) end return out end -function PaletteFX.effectiveColors(c) - return PaletteFX.permute(PaletteFX.displayColors(c), shadeMap) +-- Static native BG/OBJ bakes resolve the display without a transient BGP. +-- DARK rows lose their darkness if replaced by a mono/custom ramp; fold the +-- world shift explicitly only for such replacements. Menus stay independent. +function PaletteFX.gen2BakeColors(c, dark) + local out = PaletteFX.displayColors(c) + local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" or PaletteFX.mode == "classic" or PaletteFX.mode == "custom" + if GameVersion.isGen2() and dark and (mono or customRamp) then + out = PaletteFX.permute(out, PaletteFX.DARK_BGP) + end + return out +end +function PaletteFX.effectiveColors(c, invert) + local out = PaletteFX.displayColors(c) + if not out then return nil end + -- The shade map goes on LAST: rBGP is a hardware register write, so it + -- composes on top of whatever colors the display mode settled on -- a dark + -- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too + -- (#322). permute() is the identity (and returns `out` itself, which the + -- mod-graphics parity check leans on) while nothing has armed one. + -- An inverted font page reverses its settled display ramp before the + -- hardware shade register permutes it. Keep this on the native path so + -- Gen2 menu chrome cannot carry a second palette mode/register state. + if invert then out = { out[4], out[3], out[2], out[1] } end + return PaletteFX.permute(out, shadeMap) end -- send a 4-color (0-255 RGB) palette to the shade-remap shader, after diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7cff87285..b972b9d64 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") +local Gen2Presentation = require("src.render.Gen2Presentation") -- RESOLVED ONCE, NOT EVERY FRAME. These three were `require`d from inside -- beginFrame and endFrame -- three string hashes into package.loaded on every @@ -109,6 +110,7 @@ local function displayMetrics() end function Renderer:init() + Gen2Presentation.reset() -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua -- (#208). Every canvas below is sized in framebuffer pixels for the same -- reason -- worldViewSize() already works in drawable pixels. @@ -320,6 +322,8 @@ end -- transparent: the world pass shows through (UI pass draws overlays only) function Renderer:beginFrame(transparent) + self.gen2FrameManaged, self.gen2FrameGame, self.gen2FrameZones = nil, nil, nil + self.gen2WindowDraw = nil -- a frame that ends early -- a state that returns before endFrame, an error -- caught upstream -- must not leak a queued native-resolution draw into the -- next frame's composite @@ -715,6 +719,10 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, love.graphics.setShader() end +function Renderer:blitZones(canvas,zones,w,h) + return Gen2Presentation.blit(canvas,zones,w,h) +end + -- THE BARS, FILLED WITH THE EDGE THEY FRAME. -- -- Asked for directly: "for all gen3 menus take the outside border 1px and @@ -874,7 +882,7 @@ function Renderer:endFrame(zones, worldZones) -- stacked Game Boy screens, or driving one onto a second physical display. -- The mod returns true to take over the whole window; anything else (or no -- mod wrapping the hook) falls through to the normal composite below. - if Runtime.wantsHook("render.compose") then + if not self.gen2FrameManaged and Runtime.wantsHook("render.compose") then local ctx = { renderer = self, worldCanvas = self.worldCanvas, uiCanvas = self.canvas, @@ -931,6 +939,9 @@ function Renderer:endFrame(zones, worldZones) and self.battleWipe.screenDraw local needPresent = GBCFX.active() or Pipelines.wantsPresent() or battleNeedsSource + or (self.gen2FrameManaged and (self.gen2WindowDraw + or (type(self.gen2FrameZones)=="table" and self.gen2FrameZones[1]) + or Runtime.wantsHook("render.compose"))) local present = nil if needPresent then if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww @@ -1351,6 +1362,10 @@ function Renderer:endFrame(zones, worldZones) -- over the finished composite rather than under the UI blit. On hardware -- it is the tilemap being overwritten -- there is nothing it does not cover. local battleSource = nil + if self.gen2FrameManaged and self.gen2WindowDraw then + self.gen2WindowDraw({width=ww,height=wh,gameX=ox,gameY=oy,gameWidth=vpw,gameHeight=vph,scale=Sp,dpiX=dpiX,dpiY=dpiY}) + end + if battleNeedsSource and present then -- Snapshot the untouched finished frame into a second screen-sized canvas. -- `present` is the current render target here, so sampling it directly in @@ -1394,21 +1409,25 @@ function Renderer:endFrame(zones, worldZones) if present then love.graphics.setCanvas() - -- Post-process pipelines run over the finished composite -- world, UI - -- and all -- and before GBC FX, so a blur or colour grade is what the - -- LCD grid is then drawn over rather than something that smears the - -- grid itself. Each pass hands back a canvas; with none registered - -- this returns `present` unchanged and the frame is byte-identical. - local composed = Pipelines.present(present, - { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present - if GBCFX.active() then - -- shader grid/shadow math is in framebuffer pixels - GBCFX.present(composed, Sp) - else - -- the present canvas only existed for the post-process, so put the - -- result on the screen at the same 1:1 unit mapping it was built at - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(composed, 0, 0) + local compositionHandled=false + if self.gen2FrameManaged and Runtime.wantsHook("render.compose") then + local ctx={renderer=self,game=self.gen2FrameGame,worldCanvas=present,uiCanvas=present,sceneCanvas=present, + worldActive=self.worldActive and true or false,generation=2,zones=self.gen2FrameZones,worldZones=nil, + ww=ww,wh=wh,pw=pw,ph=ph,ox=ox,oy=oy,vpw=vpw,vph=vph,uiw=uiw,uih=uih, + scale=Sp,Sx=Sx,Sy=Sy,dpiX=dpiX,dpiY=dpiY,secondScreen=require("src.render.SecondScreen")} + love.graphics.push("all") + local ok,value=xpcall(function() + return Runtime.call("render.compose",function()return false end,self,ctx)==true + end,debug.traceback) + love.graphics.pop() + if not ok then error(value,0)end + compositionHandled=value + end + if not compositionHandled then + local source=self.gen2FrameManaged and Gen2Presentation.apply(present,self.gen2FrameZones,ww,wh)or present + local composed=Pipelines.present(source,{width=ww,height=wh,scale=Sp,dpi=dpiY,dpiX=dpiX,dpiY=dpiY})or source + if GBCFX.active()then GBCFX.present(composed,Sp) + else love.graphics.setColor(1,1,1,1);love.graphics.draw(composed,0,0)end end end self.worldActive = false diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index f22e1907b..bd36be9f2 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -5,6 +5,7 @@ local Assets = require("src.render.Assets") local PaletteFX = require("src.render.PaletteFX") +local GameVersion = require("src.core.GameVersion") local SpriteRenderer = {} SpriteRenderer.__index = SpriteRenderer @@ -46,6 +47,10 @@ end local obpCache = {} local function getObpImage(path, colors, group) + if GameVersion.isGen2() then + colors = PaletteFX.gen2BakeColors(colors, PaletteFX.darkWorld()) + group = tostring(group) .. "#" .. PaletteFX.displayKey() .. PaletteFX.darkKey() + end local key = path .. "#obp" .. group if not obpCache[key] then local img diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index a336f0035..30429842d 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -576,11 +576,18 @@ local function gen2PalColors(tileset, row) return byTod[row] or byTod.DAY or tileset.palColors end +local function gen2DisplayRows(rows, row) + if not (rows and require("src.core.GameVersion").isGen2()) then return rows end + local out = {} + for i, colors in ipairs(rows) do out[i] = PaletteFX.gen2BakeColors(colors, row == "DARK") end + return out +end + -- One atlas per (tileset image, COLORS mode, time of day). Leaving the mode -- out of this key is why switching COLORS did nothing out in the Gen2 -- overworld: the first bake won and every later mode kept being handed it. local function gen2AtlasKey(imagePath, roof, special, row) - return imagePath .. "#gen2pal:" .. tostring(PaletteFX.mode) .. ":" + return imagePath .. "#gen2pal:" .. PaletteFX.displayKey() .. ":" .. tostring(row or PaletteFX.effectiveGen2Tod()) -- TWO MAPS ON ONE TILESET ARE TWO DIFFERENT PICTURES once the roof is in -- it. Violet and Azalea share TilesetJohto1 and wear different roofs, so @@ -744,6 +751,7 @@ function TileRenderer.gen2AtlasFor(tileset) if not (tileset.palMap and #tileset.palMap > 0) then return nil end if not PaletteFX.usesGen2BgPal() then return nil end local colors = gen2PalColors(tileset) + colors = gen2DisplayRows(colors, PaletteFX.effectiveGen2Tod()) if not (colors and #colors > 0) then return nil end local perRow = tileset.tilesPerRow if not perRow or perRow < 1 then return nil end @@ -1343,6 +1351,7 @@ function TileRenderer.new(map, data) -- tileset would otherwise overwrite each other's roof. local roof = gen2RoofFor(map, data,gen2Row) gen2Colors=require('src.render.Gen2PaletteContext').withRoof(gen2Colors,roof) + gen2Colors=gen2DisplayRows(gen2Colors,gen2Row) local key = gen2AtlasKey(map.tileset.image, roof, specialKey,gen2Row) local gen2img = getGen2Atlas(key, map.tileset.image, map.tileset.tilesPerRow, map.tileset.palMap, gen2Colors, diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d6d1f500d..92c9de220 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -104,8 +104,8 @@ local function stepVolume(v, dir) end local function colorIndex(opts) - local cur = opts.colors or "gbc" - for i, m in ipairs(PaletteFX.MODES) do + local cur = GameVersion.isGen2() and PaletteFX.mode or opts.colors or "gbc" + for i, m in ipairs(PaletteFX.colorModes()) do if m == cur then return i end end return 1 @@ -370,14 +370,17 @@ local function buildRows(game) end }, { id = "colors", label = Strings("COLORS"), value = function(g) - return PaletteFX.modeLabel(g.save.options.colors or "gbc") + local mode = GameVersion.isGen2() and PaletteFX.mode or g.save.options.colors or "gbc" + return PaletteFX.modeLabel(mode) end, step = function(g, dir) local o = g.save.options local i = colorIndex(o) - i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1 - o.colors = PaletteFX.MODES[i] - PaletteFX.setMode(o.colors) + local modes = PaletteFX.colorModes() + i = wrapIndex(i - 1 + dir, #modes) + 1 + PaletteFX.writeColorOption(o, modes[i]) + if GameVersion.isGen2() then PaletteFX.setDisplay(o.colors,nil) + else PaletteFX.setMode(o.colors) end return true end }, { id = "tilt", label = Strings("TILT"), diff --git a/tests/engine/gen2_display_loader.lua b/tests/engine/gen2_display_loader.lua new file mode 100644 index 000000000..9130c5514 --- /dev/null +++ b/tests/engine/gen2_display_loader.lua @@ -0,0 +1,45 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion') +require('src.core.ModCompat').install() +local P=require('src.render.PaletteFX');local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local A={{255,255,255},{193,41,71},{31,139,83},{0,0,0}} +local R={{239,223,197},{173,137,91},{97,59,29},{19,13,7}} +local S={{217,231,243},{137,171,209},{61,103,151},{11,23,41}} +V.set('crystal') +for _,api in ipairs({1,2})do + local files={['mods/palette-cache/manifest.json']=Json.encode({id='palette_cache',name='Registered indexed texture producer',version='1.0.0',api=api,content_format=api==3 and 'unified'or'native',entry='main.lua',affects_link=false}), + ['mods/palette-cache/main.lua']=[[ + local mod=...;local G=require('src.render.GbcPalette');local Assets=require('src.render.Assets') + local cache,flushes={},0 + Assets.register(function()cache={};flushes=flushes+1 end) + mod.exports.palette=function(source) + local key=G.mode + if not cache[key]then + local resolved=G.resolve(source);local rows={} + for i=1,4 do rows[i]={resolved[i][1],resolved[i][2],resolved[i][3]}end + cache[key]=rows + end + return cache[key],flushes,key + end + ]]} + P.setDisplay('gbc',nil) + local loaded=T.sdk.loadMods({'mods/palette-cache'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,'API'..api..' actual texture producer loads') + local consumer=loaded.loader.exports.palette_cache + local initial=consumer.palette(A);T.same(initial,A,'API'..api..' initial native row') + P.setDisplay('custom',R) + local first,count,mode=consumer.palette(A) + T.same(first,R,'API'..api..' first display ramp reaches registered producer') + T.eq(mode,'custom','API'..api..' public mode follows native owner') + P.setCustomRamp(S) + local second,newCount,sameMode=consumer.palette(A) + T.eq(sameMode,mode,'API'..api..' second ramp retains public mode ID') + T.eq(newCount,count+1,'API'..api..' native change flushes registered cache once') + T.neq(second,first,'API'..api..' producer rebuilds baked colour rows') + T.same(second,S,'API'..api..' second ramp replaces stale atlas colours') + Loader.teardown();loaded.release() +end +P.setDisplay('gbc',nil) +T.finish('native Gen2 display changes invalidate loaded API 1/2 texture producers') diff --git a/tests/engine/gen2_display_modes.lua b/tests/engine/gen2_display_modes.lua new file mode 100644 index 000000000..a9b181046 --- /dev/null +++ b/tests/engine/gen2_display_modes.lua @@ -0,0 +1,138 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local V=require('src.core.GameVersion') +local P=require('src.render.PaletteFX');local G=require('src.render.GbcPalette') +local TR=require('src.render.TileRenderer');local SR=require('src.render.SpriteRenderer') +local ML=require('src.world.MapLoader');local Assets=require('src.render.Assets') +local Data=require('src.core.Data');local Game=require('src.core.Game') +local Battle=require('src.battle.BattleState') +local assetChanges=0 +Assets.register(function()assetChanges=assetChanges+1 end) +local A={{255,255,255},{201,53,71},{43,157,89},{0,0,0}} +local B={{245,239,227},{31,97,193},{167,59,131},{7,13,19}} +local R={{231,219,207},{181,151,111},{101,67,37},{23,17,11}} +local GRAY={{255,255,255},{170,170,170},{85,85,85},{0,0,0}} +local function buffer(w,h,source) + local b={w=w,h=h,pixels={}} + function b:getDimensions()return self.w,self.h end + function b:getWidth()return self.w end;function b:getHeight()return self.h end + function b:getPixel(x,y) + local p=self.pixels[y*self.w+x];if p then return unpack(p)end + local n=source and 1-x%4/3 or 0;return n,n,n,source and 1 or 0 + end + function b:setPixel(x,y,...)self.pixels[y*self.w+x]={...}end + function b:mapPixel(f)for y=0,self.h-1 do for x=0,self.w-1 do self:setPixel(x,y,f(x,y,self:getPixel(x,y)))end end end + function b:setFilter()end;function b:setWrap()end;function b:release()end + return b +end +love.image={newImageData=function(w,h)return buffer(w,h)end} +love.graphics.newImage=function(b)return b end +local function source(path) + if path=='display-tiles' then return buffer(16,8,true)end + if path=='display-roof' then return buffer(8,8,true)end + return buffer(16,96,true) +end +Assets.image=source;Assets.imageData=source +local function pixels(image,colors,start,keyed,label) + for i=1,4 do + local r,g,b,a=image:getPixel((start or 0)+i-1,0) + if keyed and i==1 then T.eq(a,0,label..' OBJ shade zero remains transparent') + else T.same({r,g,b,a},{colors[i][1]/255,colors[i][2]/255,colors[i][3]/255,1},label..' shade '..i)end + end +end +local function fixture() + local d=T.fixtures.fresh() + local ts={id='DISPLAY',image='display-tiles',tilesPerRow=2,palMap={0,1},palColorsByTod={DAY={A,B},NITE={B,A},DARK={A,B}}, + animatedTiles={{tile=0,kind='hshift',offsets={0,1},period=20}},blockCells=2,blocks={{0,1,0,1}},walkable={0},grassTiles={},waterTiles={}} + d.tilesets={DISPLAY=ts} + d.maps={DISPLAY={id='DISPLAY',label='DISPLAY',width=1,height=1,blocks={0},tileset='DISPLAY',environment=1,mapPalette=1,group=1,number=1}} + d.pokemon={TEST={id='TEST',name='TEST',index=1,dex=1,palette='QA_TEST',spriteFront='display-mon'}} + d.palettes={palettes={QA_TEST=A,MEWMON=A,GRAYMON=GRAY,BLACK={{0,0,0},{0,0,0},{0,0,0},{0,0,0}}},pokemon={TEST='QA_TEST'}} + d.field.gen2Roofs={tilesets={DISPLAY=true},byGroup={[1]=0},images={[0]='display-roof'},slot=1,count=1,palIndex=1, + colors={[1]={DAY={{53,63,73},{83,93,103}}}}} + Data.publishGen2Palettes(d) + return d,ts +end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);P.setDisplay('gbc',nil);P.setShadeMap(nil);P.setDarkWorld(false);P.setGen2MapPalette(1);P.setGen2Tod('DAY') + local d,ts=fixture();ML.invalidateAll() + local mon={species='TEST',dvs={attack=1,defense=1,speed=1,special=1}} + local battle=setmetatable({data=d,game={save={party={}}}},Battle) + local factory + for i=1,100 do local name,value=debug.getupvalue(Battle.speciesSprite,i);if name=='getImage'then factory=value;break end end + assert(factory,'native battle image factory unavailable') + local originalPic=factory('display-mon',{name='QA_TEST',colors=A},false) + local sprite=SR.new({id='DISPLAY',image='display-sprite',gen2ObjPal=A}) + local reloads=0 + Game.overworld={map=ML.load(d,'DISPLAY'),reloadMap=function(self,id,reason) + T.eq(reason,'colors',edition..' native map reload identifies display change') + self.map=ML.load(d,id);self.tiles=TR.new(self.map,d);reloads=reloads+1 + end} + for _,choice in ipairs({{'gbc',A},{'dmg',GRAY},{'classic',GRAY},{'custom',R}})do + local name,wanted=choice[1],choice[2] + local oldMap=Game.overworld.map + if name=='custom' then P.setDisplay(name,R)else P.setDisplay(name,nil)end + T.eq(G.mode,name,edition..' native/public mode agrees '..name) + T.eq(G.customRamp,name=='custom'and R or nil,edition..' public ramp is actual native ramp') + if name~='gbc' then T.neq(Game.overworld.map,oldMap,edition..' current map cache replaced '..name)end + local tiles=TR.new(ML.load(d,'DISPLAY'),d) + pixels(tiles.image,wanted,0,false,edition..' actual atlas '..name) + local roof=name=='gbc'and{B[1],{53,63,73},{83,93,103},B[4]}or wanted + pixels(tiles.image,roof,8,false,edition..' actual roof '..name) + T.check(tiles.anims and tiles.anims[1],edition..' native water animation exists') + pixels(tiles.anims[1].textures[1],wanted,0,false,edition..' actual animated tile '..name) + pixels(sprite:resolveImage(),wanted,0,true,edition..' retained native sprite '..name) + pixels(battle:picImage(originalPic),wanted,0,false,edition..' retained native battle picture '..name) + battle.fx={bgp={[0]=3,2,1,0}} + pixels(battle:picImage(originalPic),{wanted[4],wanted[3],wanted[2],wanted[1]},0,false,edition..' battle fade after display '..name) + battle.fx=nil;battle.blackedOut=true + pixels(battle:picImage(originalPic),{wanted[4],wanted[4],wanted[4],wanted[4]},0,false,edition..' battle blackout '..name) + battle.blackedOut=false + if name=='classic' then T.eq(G.presentColors(),P.CLASSIC,edition..' Classic has one final green ramp') + else T.eq(G.presentColors(),nil,edition..' mode has no extra green pass '..name)end + end + local count=reloads;local assetCount=assetChanges;G.setCustomRamp(R);T.eq(reloads,count,edition..' identical ramp avoids reload') + T.eq(assetChanges,assetCount,edition..' identical ramp avoids shared asset churn') + local oldKey=P.displayKey();R[2][1]=R[2][1]+1;G.setCustomRamp(R) + T.neq(P.displayKey(),oldKey,edition..' edited ramp changes native cache key') + T.eq(reloads,count+1,edition..' reapplying edited ramp refreshes current map once') + T.eq(assetChanges,assetCount+1,edition..' edited ramp notifies registered external caches once') + pixels(sprite:resolveImage(),R,0,true,edition..' sprite rebakes edited ramp') + G.setBgp(0xff) + pixels(TR.new(ML.load(d,'DISPLAY'),d).image,R,0,false,edition..' transient BGP never baked into atlas') + G.setBgp(nil);P.setDarkWorld(true) + pixels(sprite:resolveImage(),{R[3],R[4],R[4],R[4]},0,true,edition..' custom world darkness preserved') + P.setDarkWorld(false) + T.eq(G.cycle(), 'dmg',edition..' custom cycle returns to native ladder') + T.eq(G.customRamp,nil,edition..' cycling removes custom ramp') + T.eq(G.cycle(),'classic',edition..' DMG advances to Classic') + T.eq(G.cycle(),'gbc',edition..' Classic advances to GBC') + T.eq(G.cycle(-1),'classic',edition..' backward cycle supported') + count=reloads;assetCount=assetChanges;G.applyOptions({color='custom',palette='m:80a0c0'}) + T.eq(reloads,count+1,edition..' saved mode and palette applied atomically') + T.eq(assetChanges,assetCount+1,edition..' saved mode and palette flush shared assets once') + T.same(G.customRamp,{{128,160,192},{92,115,138},{49,61,73},{15,19,23}},edition..' saved RGB palette reaches native owner') + local opts={color='custom',colors='custom',palette='m:80a0c0'} + P.writeColorOption(opts,P.cycleMode());T.eq(opts.palette,nil,edition..' manual cycle clears saved palette') + T.eq(opts.color,'dmg',edition..' legacy option tracks actual new mode');T.eq(opts.colors,'og',edition..' native option tracks same mode') + G.applyOptions(opts);T.eq(G.mode,'dmg',edition..' reapply cannot revive stale custom selection') + local menuGame={data=d,save={options=opts}} + local menu=require('src.ui.OptionsMenu').new(menuGame) + local row;for _,candidate in ipairs(menu.rows)do if candidate.id=='colors'then row=candidate end end + T.check(row,edition..' actual native options has display row') + T.eq(row.value(menuGame),'DMG',edition..' options reports actual native selection') + row.step(menuGame,1) + T.eq(opts.color,'classic',edition..' options persists legacy Classic selection') + T.eq(opts.colors,'classic',edition..' options persists same native selection') + T.eq(G.mode,'classic',edition..' actual options changes native renderer mode') + T.eq(row.value(menuGame),'CLASSIC',edition..' options reports switched mode') + row.step(menuGame,-1);G.applyOptions(opts) + T.eq(G.mode,'dmg',edition..' backwards options choice survives apply') + Game.overworld=nil +end +V.set('emerald');P.setDisplay('classic',R);T.eq(P.displayColors(A),A,'Gen3 palette behavior preserved');T.eq(P.presentColors(),nil,'Gen3 receives no Classic pass') +V.set('red');P.setDisplay('classic',nil);T.eq(P.displayColors(A),P.CLASSIC,'Gen1 Classic remains its original palette path') +T.finish('native Gen2 modes, cached atlas/roof/animation/OBJ/battle bakes and saved options') diff --git a/tests/engine/gen2_palette_draw.lua b/tests/engine/gen2_palette_draw.lua index c8aeef1d8..c60ec63a5 100644 --- a/tests/engine/gen2_palette_draw.lua +++ b/tests/engine/gen2_palette_draw.lua @@ -66,7 +66,7 @@ for _,game in ipairs({'red','gold','silver','crystal'})do for _,mode in ipairs(P.MODES)do P.setMode(mode);P.setShadeMap({[0]=3,[1]=1,[2]=2,[3]=0}) local resolved=P.effectiveColors(A) - local display=game~='red'and'gbc'or(mode=='classic'and'classic'or((mode=='og'or mode=='og_inv')and'dmg'or'gbc')) + local display=mode=='classic'and'classic'or((mode=='og'or mode=='og_inv')and'dmg'or'gbc') T.eq(G.mode,display,game..' '..mode..' public mode follows actual native resolution') local trueColor=true T.eq(not(trueColor and G.mode=='gbc'),display~='gbc',game..' '..mode..' unchanged evolution colour gate preserves true-colour art') diff --git a/tests/engine/palette_library.lua b/tests/engine/palette_library.lua new file mode 100644 index 000000000..3ee468219 --- /dev/null +++ b/tests/engine/palette_library.lua @@ -0,0 +1,50 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local library={{name='Authored',palettes={ + {'BLUE','00112233445566778899aabb'}, + {'GRAY','000000555555aaaaaaffffff'}, + {'MIX','ff000000ff000000ffffffff'}, + {'INVALID','not a ramp'}, +}}} +package.loaded['data.gb_palettes']=library +local P=require('src.render.Palette') +T.eq(P.packs(),library,'named ramps come from actual optional authored library') +T.eq(P.packId('Authored','BLUE'),'p:Authored/BLUE','named palette ID') +T.eq(P.monoId(128,160,192),'m:80a0c0','RGB palette ID') +T.same(P.ramp('m:80a0c0'),{{128,160,192},{92,115,138},{49,61,73},{15,19,23}},'mono ramp uses public four-step intensities') +T.same(P.unhex('00112233445566778899aabb'),{{0,17,34},{51,68,85},{102,119,136},{153,170,187}},'hex row order') +T.same(P.ramp('p:Authored/BLUE'),{{153,170,187},{102,119,136},{51,68,85},{0,17,34}},'named library order reversed into light-to-dark drawing ramp') +T.same(P.swatch('p:Authored/BLUE'),{{0,17,34},{51,68,85},{102,119,136},{153,170,187}},'swatch reverses drawing ramp') +T.eq(P.findPack('Authored','BLUE'),library[1].palettes[1],'lookup keeps library ownership') +T.eq(P.label('p:Authored/BLUE'),'BLUE','named display label') +T.eq(P.label('m:9bbc0f'),'GREEN','known mono colour label') +T.eq(P.label('m:123456'),'MONO','authored mono colour label') +T.eq(P.label('unknown'),'unknown','unknown ID label remains observable') +T.eq(P.label(''),nil,'empty label absent') +for _,id in ipairs({'m:bad','m:1234567','m:xxxxxx','p:missing/entry','p:Authored/INVALID',''})do + T.eq(P.ramp(id),nil,'invalid/missing palette not invented: '..id) +end +for _,value in ipairs({false,'','00112233445566778899aabx','00112233445566778899aab'})do + T.eq(P.unhex(value),nil,'invalid hex refused') +end +T.eq(P.categoryOf(P.unhex('000000555555aaaaaaffffff')),'grey','gray category') +T.eq(P.categoryOf(P.unhex('00112233445566778899aabb')),'single','single hue category') +T.eq(P.categoryOf(P.unhex('ff000000ff000000ffffffff')),'full','full hue category') +T.eq(P.categoryOf({}),nil,'incomplete ramp has no category') +T.eq(P.hueArc({350,10}),20,'hue arc crosses zero') +T.eq(P.hueArc({}),360,'empty hue-arc protocol') +local hue,chroma=P.hueChroma({255,0,0});T.eq(hue,0,'red hue');T.eq(chroma,255,'red chroma') +local categories=P.categories();T.eq(#categories,3,'three public categories') +T.eq(categories[1].key,'full','category order preserved');T.eq(#categories[1].palettes,1,'invalid ramp omitted') +local key,at=P.locate('p:Authored/BLUE');T.eq(key,'single','locate category');T.eq(at,1,'locate index') +T.eq(P.category('single').palettes[1].pack,'Authored','category entry keeps source pack') +T.eq(P.category('unknown'),nil,'unknown category absent') +T.eq(P.locate('m:80a0c0'),nil,'mono ramp is not a named-library row') +local old=P.ramp('p:Authored/BLUE');T.eq(P.ramp('p:Authored/BLUE'),old,'same ramp ID retains cached table') +library[1].palettes[1][2]='112233445566778899aabbcc';P.invalidate() +local updated=P.ramp('p:Authored/BLUE');T.neq(updated,old,'invalidate rebuilds ramps') +T.same(updated[1],{170,187,204},'updated authored library reaches drawing ramp') +T.eq(P.packs(),library,'invalidating derived indices preserves library owner') +T.finish('native public palette IDs, authored libraries and colour categories') diff --git a/tests/gpu/gen2_display_modes.lua b/tests/gpu/gen2_display_modes.lua new file mode 100644 index 000000000..798caa694 --- /dev/null +++ b/tests/gpu/gen2_display_modes.lua @@ -0,0 +1,153 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- ROM-free native GPU composition, including final CGB zones and window UI. +local M={} +function M.run() + require('src.core.ModCompat').install() + local P=require('src.render.PaletteFX');local G=require('src.render.GbcPalette') + local V=require('src.core.GameVersion');local Renderer=require('src.render.Renderer') + local Present=require('src.render.Gen2Presentation');local haveViewport,Viewport=pcall(require,'src.render.GameViewport') + if not haveViewport then Viewport={setTarget=function()love.graphics.setCanvas()end}end + local Runtime=require('src.mods.Runtime');local Hooks=require('src.mods.Hooks') + local g=love.graphics + local A={{255,255,255},{201,53,71},{43,157,89},{0,0,0}} + local R={{231,219,207},{181,151,111},{101,67,37},{23,17,11}} + local GRAY={{255,255,255},{170,170,170},{85,85,85},{0,0,0}} + local original={version=V.get(),mode=P.mode,ramp=P.customRamp(),shadeMap=P.shadeMap(),setTarget=Viewport.setTarget,setCanvas=g.setCanvas, + events=Runtime.events,hooks=Runtime.hooks,errors=Runtime.errors} + local w,h=g.getDimensions() + local output=g.newCanvas(w,h,{format='rgba8',dpiscale=1}) + local sheet=g.newCanvas(w,h*4,{format='rgba8',dpiscale=1}) + M.image=sheet + local data=love.image.newImageData(4,1,'rgba8') + for x=0,3 do local n=1-x/3;data:setPixel(x,0,n,n,n,1)end + local image=g.newImage(data);image:setFilter('nearest','nearest') + local checks=0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local function same(actual,wanted,label) + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255,label..' channel '..c..' got '..actual[c]..' expected '..wanted[c])end + end + local function color(c)return {c[1]/255,c[2]/255,c[3]/255,1}end + local function sample(pixels,x,y)return {pixels:getPixel(math.floor(x),math.floor(y))}end + local game={nativeGpuOwner=true} + local bus=Hooks.new();local zoneCalls,composeCalls=0,0 + local nativeViewport + if haveViewport then Viewport.setTarget=function()g.setCanvas(output)end + else local setCanvas=g.setCanvas;g.setCanvas=function(canvas,...)return setCanvas(canvas or output,...)end end -- bounded output IO only + g.push('all') + local ok,why=xpcall(function() + V.set('crystal');P.setShadeMap(nil);P.setDarkWorld(false) + local haveCorrection,Correction=pcall(require,'src.render.GbcColorCorrection');if haveCorrection then Correction.setMode('off')end + require('src.render.GBCFX').setLevel(0) + local havePresets,Presets=pcall(require,'src.render.ShaderPresets');if havePresets then Presets:off()end + Runtime.install(original.events,bus,{}) + Renderer:init() + local function scene(windowMenu) + g.setCanvas(output);g.origin();g.clear(0,0,0,1) + Renderer:beginFrame() + g.setColor(1,1,1,1) + G.with(A,function()g.draw(image,0,0,0,40,72)end) + local native=P.shader() + local cached=require('src.core.Performance').paletteZoneCache + if cached~=nil then check(cached,'native optimized upload enabled')end + P.sendColors(native,A);g.setShader(native);g.draw(image,0,72,0,40,72);g.setShader() + Renderer.gen2FrameManaged=true;Renderer.gen2FrameGame=game + Renderer.gen2FrameZones=Present.zones(game) + if windowMenu then Renderer.gen2WindowDraw=function() + g.setColor(1,1,1,1);g.rectangle('fill',0,0,w,12) + end end + nativeViewport=Renderer:endFrame(nil,nil) + g.setCanvas();g.setShader() + original.setCanvas() -- leave the bounded legacy output target before readback + return g.readbackTexture(output) + end + for i,choice in ipairs({{'gbc',A},{'dmg',GRAY},{'classic',P.CLASSIC},{'custom',R}})do + local mode,wanted=choice[1],choice[2] + P.setDisplay(mode,mode=='custom' and R or nil) + local pixels=scene(false) + for _,y in ipairs({36,108})do for x=0,3 do + same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+y*nativeViewport.scale),color(wanted[x+1]),'native/public composite '..mode..' shade '..x..' row '..y) + end end + g.setCanvas(sheet);g.origin();g.setShader();g.setColor(1,1,1,1);g.setBlendMode('replace','premultiplied');g.draw(output,0,(i-1)*h) + g.setCanvas();pixels:release() + end + P.setDisplay('classic',nil) + local pixels=scene(true) + same(sample(pixels,w-4,4),color(P.CLASSIC[1]),'native window-space menu included before Classic at top right') + same(sample(pixels,w-4,h-4),color(P.CLASSIC[4]),'native final image keeps bottom-right orientation') + pixels:release() + local detachZones=bus:wrap('render.zones',function(next,owner,zones) + zoneCalls=zoneCalls+1 + check(owner==game,'zone hook receives actual game owner') + check(zones[1].colors==P.CLASSIC,'zone hook observes final green ramp') + -- Override final Classic as the public seam permits. Resolve must not + -- turn this authored destination back into grayscale. + zones[1].colors=R + return next(owner,zones) + end,0,'gpu-palette-consumer') + pixels=scene(false) + check(zoneCalls==1,'zone hook called once for completed frame') + for x=0,3 do same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+72*nativeViewport.scale),color(R[x+1]),'authored final zone shade '..x)end + pixels:release();detachZones() + local detachCompose=bus:wrap('render.compose',function(_,renderer,ctx) + composeCalls=composeCalls+1 + check(renderer==Renderer and ctx.renderer==Renderer,'native renderer remains composition owner') + check(ctx.game==game and ctx.generation==2,'composition identifies actual game/generation') + check(ctx.worldCanvas==ctx.sceneCanvas and ctx.uiCanvas==ctx.sceneCanvas,'composition receives completed Gen2 scene') + check(ctx.zones[1].colors==P.CLASSIC,'composition receives resolved final palette') + check(g.getCanvas()~=ctx.sceneCanvas,'composition never reads its active render target') + -- Taking ownership bypasses the ordinary tint and post-process, so a + -- reference-style consumer can present the supplied zones itself. + renderer:blitZones(ctx.sceneCanvas,ctx.zones,ctx.ww,ctx.wh) + return true + end,0,'gpu-frame-consumer') + pixels=scene(false) + check(composeCalls==1,'compose invoked once after native scene') + for x=0,3 do same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+72*nativeViewport.scale),color(P.CLASSIC[x+1]),'composition-owned final shade '..x)end + pixels:release();detachCompose() + -- Empty/backwards/outside rectangles are valid no-op draws. A false + -- palette is a raw RGB opt-out, and scoped state must be restored. + local raw=g.newCanvas(4,1,{format='rgba8',dpiscale=1}) + g.setCanvas(raw);g.clear(0,0,0,1) + local external=G.shader();G.useRaw(A) + Present.blit(image,{{x=0,y=0,w=-1,h=144,colors=R},{x=200,w=5,colors=R},{x=0,y=0,w=160,h=144,colors=false}},4,1) + check(g.getShader()==external,'public final blit restores existing shader') + g.setCanvas();g.setShader();local plain=g.readbackTexture(raw) + for x=0,3 do same(sample(plain,x,0),color(GRAY[x+1]),'false-zone raw RGB '..x)end + g.setCanvas(raw);G.useRaw(A) + Present.blit(image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + check(g.getShader()==external,'authored final blit restores shader identity') + g.draw(image,0,0);g.setCanvas();g.setShader() + local restored=g.readbackTexture(raw) + for x=0,3 do same(sample(restored,x,0),color(A[x+1]),'authored final blit restores managed uniforms '..x)end + g.setCanvas(raw);G.useRaw(A) + Present.apply(image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + check(g.getCanvas()==raw and g.getShader()==external,'offscreen final pass restores target and shader') + g.draw(image,0,0);g.setCanvas();g.setShader() + local applied=g.readbackTexture(raw) + for x=0,3 do same(sample(applied,x,0),color(A[x+1]),'offscreen final pass restores managed uniforms '..x)end + local draw=g.draw + g.draw=function(source,...) + if source==image then error('authored final draw failure',0)end + return draw(source,...) + end + g.setCanvas(raw);G.useRaw(A) + local failed,reason=pcall(Present.apply,image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + g.draw=draw + check(not failed and tostring(reason):find('authored final draw failure',1,true),'final draw error propagates') + check(g.getCanvas()==raw and g.getShader()==external,'failed final pass restores target and shader') + g.draw(image,0,0);g.setCanvas();g.setShader() + local recovered=g.readbackTexture(raw) + for x=0,3 do same(sample(recovered,x,0),color(A[x+1]),'failed final pass restores managed uniforms '..x)end + recovered:release() + restored:release();applied:release() + raw:release();plain:release() + end,debug.traceback) + g.setCanvas();g.pop() + g.setCanvas=original.setCanvas;g.setCanvas();Viewport.setTarget=original.setTarget;Runtime.install(original.events,original.hooks,original.errors) + V.set(original.version);P.setDisplay(original.mode,original.ramp);P.setShadeMap(original.shadeMap) + output:release();image:release();data:release() + if not ok then error(why,0)end + return {passed=true,checks=checks,scope='Native Gen2 GPU composition in GBC/DMG/Classic/custom, final Classic/zone hooks, completed-scene composition ownership, window-space menus, scoped raw blits'},sheet +end +return M From 2383d847bea75fe07c520ae9ceabbb89d73e6109 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:21:00 +0100 Subject: [PATCH 055/187] Draw native Gen2 menu owners at output resolution --- src/core/Game.lua | 30 ++++++++++- src/render/ScreenSpaceUI.lua | 93 ++++++++++++++++++++++++++++++++ tests/engine/screen_space_ui.lua | 68 +++++++++++++++++++++++ tests/gpu/gen2_display_modes.lua | 8 ++- tests/gpu/gen2_native_stack.lua | 41 ++++++++++++++ 5 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 src/render/ScreenSpaceUI.lua create mode 100644 tests/engine/screen_space_ui.lua create mode 100644 tests/gpu/gen2_native_stack.lua diff --git a/src/core/Game.lua b/src/core/Game.lua index 60bd2784c..4a3db7a15 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1013,6 +1013,13 @@ function Game:_draw() -- a world-bg battle keeps the map drawing under whatever it opened, so the -- world pass can run for a frame whose CLEAR is still an opaque menu's local drawFrom = Game.drawBaseInStack(self.stack, base) + local ScreenSpaceUI=require('src.render.ScreenSpaceUI') + local scene=self.stack + local windowUI,windowUIIndex=ScreenSpaceUI.select(self.stack,scene,drawFrom) + local windowUIOverlay=false + local nativeWindowOverlays=windowUI and require('src.core.GameVersion').isGen2() + local nativeWindowStack=not windowUI and require('src.core.GameVersion').isGen2() + and ScreenSpaceUI.hasNativeStack(self.stack,scene,drawFrom,self.overworld) local worldDrawn = self.stack.states[drawFrom] == self.overworld -- A wide battle holds its 304px surface through every menu or prompt it -- opens. States that do not draw the wide battle composition are centred @@ -1095,7 +1102,13 @@ function Game:_draw() local ownsSurface = wideState or (native ~= nil and state == native) or Game.wantsThisSurface(state) - if state and state.draw then + if nativeWindowOverlays and windowUIIndex and i>windowUIIndex then + if state and state.draw then windowUIOverlay=true end + elseif nativeWindowStack and state~=self.overworld then + -- Native controllers draw once on the full output surface below. + elseif state==windowUI then + love.graphics.clear(0,0,0,0) + elseif state and state.draw then -- ...and it is centred by ITS OWN size, not by the Game Boy's (see -- Game.homeOffset). classicOffset still gates it: a surface that is -- the Game Boy's centres nothing, which is every frame outside a wide @@ -1117,7 +1130,7 @@ function Game:_draw() end local baseState = self.stack.states[drawFrom] local SuperSample = require("src.render.SuperSample") - if not worldDrawn and baseState and require("src.core.GameVersion").isNds() + if not windowUI and not worldDrawn and baseState and require("src.core.GameVersion").isNds() and SuperSample.singleScreen(self) and not SuperSample.active then SuperSample.draw(self, self.stack:top(), drawStates) else @@ -1187,7 +1200,20 @@ function Game:_draw() if worldDrawn and self.overworld.sgbWorldZones then worldZones = self.overworld:sgbWorldZones() end + if Renderer.gen2FrameManaged and (windowUI or nativeWindowStack) then + Renderer.gen2WindowDraw=function(viewport) + if windowUI then + ScreenSpaceUI.draw(windowUI,Renderer,viewport,zones,false) + if windowUIOverlay then ScreenSpaceUI.drawNativeStack(self.stack,scene,windowUIIndex+1, + self.overworld,Renderer,viewport,false)end + else ScreenSpaceUI.drawNativeStack(self.stack,scene,drawFrom,self.overworld, + Renderer,viewport,not worldDrawn)end + end + end local viewport = Renderer:endFrame(zones, worldZones) + if windowUI and not Renderer.gen2FrameManaged then + ScreenSpaceUI.draw(windowUI,Renderer,viewport,zones,windowUIOverlay) + end -- Persistent tool status is screen-space UI: draw it over the completed -- render pipeline with exact playfield/margin geometry, but below mobile -- controls. It never becomes an updating game state. diff --git a/src/render/ScreenSpaceUI.lua b/src/render/ScreenSpaceUI.lua new file mode 100644 index 000000000..0f04a3bc7 --- /dev/null +++ b/src/render/ScreenSpaceUI.lua @@ -0,0 +1,93 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Optional window-resolution UI. Simulation/input remain native; only an +-- explicitly advertised drawer leaves the cartridge-sized UI canvas. +local M = {} +local function visible(stack, state) + return not stack.renderVisible or stack:renderVisible(state) +end +function M.select(stack, scene, first) + for i = #scene.states, first, -1 do + local state = scene.states[i] + if visible(stack, state) and type(state.drawsWidescreen) == 'function' + and type(state.drawWidescreen) == 'function' and state:drawsWidescreen() then + return state, i + end + end +end +function M.classic(draw, renderer, width, height) + local G = love.graphics + local uw, uh = renderer:uiSize() + local p = renderer.uiPresentation + if not p then + local scale = math.max(1, math.floor(math.min(width / uw, height / uh))) + p = { x=(width-uw*scale)/2, y=(height-uh*scale)/2, scaleX=scale, scaleY=scale } + end + G.push('all') + G.origin() + G.translate(p.x+(uw-renderer.WIDTH)*p.scaleX/2, p.y+(uh-renderer.HEIGHT)*p.scaleY/2) + G.scale(p.scaleX,p.scaleY) + local ok, err = pcall(draw) + G.pop() + if not ok then error(err,0) end +end +-- Gen2's reference renderer paints native overlays on its full-resolution +-- scene, under a cartridge-grid transform. A drawer that resets origin() +-- therefore gets real window pixels, not a 160x144 intermediate texture. +function M.hasNativeStack(stack, scene, first, world) + for i=first,#scene.states do + local state=scene.states[i] + if state and state~=world and visible(stack,state) and type(state.draw)=='function' then return true end + end + return false +end +function M.drawNativeStack(stack, scene, first, world, renderer, viewport, opaque) + local G=love.graphics + G.push('all');G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok,err=pcall(function() + if opaque then G.rectangle('fill',0,0,viewport.width,viewport.height) end + M.classic(function() + for i=first,#scene.states do + local state=scene.states[i] + if state and state~=world and visible(stack,state) and type(state.draw)=='function' then + G.push('all') + -- Declared larger native surfaces retain their own origin. Plain + -- GB states stay centred in a parent's expanded surface. + if type(state.uiSize)=='function' then + local sized,w,h=pcall(state.uiSize,state) + if sized and type(w)=='number' and w>0 and type(h)=='number' and h>0 then + G.translate((renderer.WIDTH-w)/2,(renderer.HEIGHT-h)/2) + end + end + local drawn,why=pcall(state.draw,state) + G.pop() + if not drawn then error(why,0) end + end + end + end,renderer,viewport.width,viewport.height) + end) + G.pop() + if not ok then error(err,0) end +end +function M.draw(owner, renderer, viewport, zones, overlay) + local G = love.graphics + G.push('all') + G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok, err = pcall(owner.drawWidescreen, owner, viewport.width, viewport.height) + G.pop() + if not ok then error(err,0) end + -- The classic canvas contains only states above this owner. Reapply it + -- after the high-resolution drawer so a native textbox keeps its parent. + if overlay then + local p = renderer.uiPresentation + if not p then + p = {x=viewport.gameX,y=viewport.gameY,scaleX=viewport.scale/(viewport.dpiX or 1),scaleY=viewport.scale/(viewport.dpiY or 1)} + end + G.push('all');G.origin();G.setShader();G.setColor(1,1,1,1);G.setBlendMode('alpha','premultiplied') + local overlayOK, overlayError = pcall(renderer.blitCanvas,renderer,renderer.canvas,p.scaleX,p.scaleY,zones,p.scaleX,p.scaleY, + p.x,p.y,0,0,viewport.width,viewport.height,viewport.dpiX or 1,viewport.dpiY or 1) + G.pop() + if not overlayOK then error(overlayError,0) end + end +end +return M diff --git a/tests/engine/screen_space_ui.lua b/tests/engine/screen_space_ui.lua new file mode 100644 index 000000000..c983542a4 --- /dev/null +++ b/tests/engine/screen_space_ui.lua @@ -0,0 +1,68 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local UI=require('src.render.ScreenSpaceUI') +local stack=setmetatable({},{__index=require('src.core.StateStack')});stack:init() +local scene={states={}} +T.eq(UI.select(stack,scene,1),nil,'ordinary empty frame requests no window-resolution UI') +local trace={} +local parent={drawsWidescreen=function()return true end,drawWidescreen=function(_,w,h) + trace[#trace+1]='wide';T.eq(w,800,'window width reaches the advertised drawer');T.eq(h,720,'window height reaches the advertised drawer') + love.graphics.setColor(1,0,0,1) +end} +local overlay={draw=function()end} +scene.states={parent,overlay} +local owner,index=UI.select(stack,scene,1) +T.eq(owner,parent,'native overlay retains the visible wide parent') +T.eq(index,1,'wide owner position is preserved for overlay separation') +parent.drawsWidescreen=function()return false end +T.eq(UI.select(stack,scene,1),nil,'drawer alone does not opt in') +parent.drawsWidescreen=function()return true end +T.eq(UI.select(stack,scene,2),nil,'hidden below draw boundary is not resurrected') +local renderer={canvas={},WIDTH=160,HEIGHT=144,uiPresentation={x=0,y=0,scaleX=5,scaleY=5}} +function renderer:uiSize()return 160,144 end +function renderer:blitCanvas(canvas,sx,sy) + trace[#trace+1]='overlay';T.eq(canvas,self.canvas,'overlay reuses native canvas');T.eq(sx,5,'native overlay horizontal scale');T.eq(sy,5,'native overlay vertical scale') +end +local viewport={width=800,height=720,gameX=0,gameY=0,scale=5,dpiX=1,dpiY=1} +love.graphics.setColor(.2,.3,.4,1) +UI.draw(parent,renderer,viewport,nil,true) +T.same(trace,{'wide','overlay'},'native prompt is composited after its window-resolution parent') +local r,g,b=love.graphics.getColor();T.eq(r,.2,'drawer graphics state restored');T.eq(g,.3,'drawer cannot leak color');T.eq(b,.4,'overlay graphics state restored') +trace={};UI.draw(parent,renderer,viewport,nil,false) +T.same(trace,{'wide'},'frame without overlay does not duplicate the cartridge canvas') +parent.drawWidescreen=function()love.graphics.setColor(0,0,0,1);error('deliberate wide drawer failure')end +T.raises(function()UI.draw(parent,renderer,viewport,nil,false)end,'deliberate wide drawer failure','drawer errors remain observable') +T.eq(select(1,love.graphics.getColor()),.2,'failed drawer still restores graphics state') +local drew=0;UI.classic(function()drew=drew+1 end,renderer,800,720) +T.eq(drew,1,'classic fallback executes native draw exactly once') +-- Native Gen2 controllers can draw on the full-size output without a +-- low-resolution intermediate, while retaining their native stack lifetime. +trace={};local world={draw=function()trace[#trace+1]='world' end} +local hidden={draw=function()error('hidden native state drawn')end} +local output={width=800,height=720} +local native={draw=function(self) + T.eq(love.graphics.getCanvas(),output,'native drawer receives output canvas directly') + trace[#trace+1]='native';love.graphics.setColor(0,1,0,1) +end} +local front={draw=function() + local r=love.graphics.getColor();T.eq(r,1,'preceding native drawer cannot leak its graphics state') + trace[#trace+1]='front' +end} +scene.states={world,hidden,native,front} +local filtered={renderVisible=function(_,s)return s~=hidden end} +T.eq(UI.hasNativeStack(filtered,scene,1,world),true,'visible native stack requests output-resolution drawing') +T.eq(UI.hasNativeStack(filtered,{states={world,hidden}},1,world),false,'world and mirrored states alone request no UI pass') +love.graphics.setCanvas(output);love.graphics.setColor(.2,.3,.4,1) +UI.drawNativeStack(filtered,scene,1,world,renderer,viewport,false) +T.same(trace,{'native','front'},'world stays single-pass and native stack draw order is retained') +T.eq(love.graphics.getCanvas(),output,'native pass restores output target') +T.eq(select(1,love.graphics.getColor()),.2,'native pass restores caller color') +trace={};UI.drawNativeStack(filtered,scene,4,world,renderer,viewport,false) +T.same(trace,{'front'},'native overlays above wide owner exclude that owner') +native.draw=function()love.graphics.setCanvas({});error('native drawer failure')end +T.raises(function()UI.drawNativeStack(filtered,scene,3,world,renderer,viewport,false)end,'native drawer failure','native errors remain visible') +T.eq(love.graphics.getCanvas(),output,'failed native drawer restores output target') +love.graphics.setCanvas() +T.finish('screen-space UI owner, native overlay ordering and graphics isolation') diff --git a/tests/gpu/gen2_display_modes.lua b/tests/gpu/gen2_display_modes.lua index 798caa694..71826794c 100644 --- a/tests/gpu/gen2_display_modes.lua +++ b/tests/gpu/gen2_display_modes.lua @@ -53,8 +53,12 @@ function M.run() P.sendColors(native,A);g.setShader(native);g.draw(image,0,72,0,40,72);g.setShader() Renderer.gen2FrameManaged=true;Renderer.gen2FrameGame=game Renderer.gen2FrameZones=Present.zones(game) - if windowMenu then Renderer.gen2WindowDraw=function() - g.setColor(1,1,1,1);g.rectangle('fill',0,0,w,12) + if windowMenu then Renderer.gen2WindowDraw=function(viewport) + local vector={draw=function() + g.origin();g.setColor(1,1,1,1);g.rectangle('fill',0,0,w,12) + end} + require('src.render.ScreenSpaceUI').drawNativeStack({}, + {states={vector}},1,nil,Renderer,viewport,false) end end nativeViewport=Renderer:endFrame(nil,nil) g.setCanvas();g.setShader() diff --git a/tests/gpu/gen2_native_stack.lua b/tests/gpu/gen2_native_stack.lua new file mode 100644 index 000000000..755993335 --- /dev/null +++ b/tests/gpu/gen2_native_stack.lua @@ -0,0 +1,41 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Output pixels for the native Gen2 menu layer, independently of screen providers. +local M={} +function M.run() + local g=love.graphics;local Renderer=require('src.render.Renderer') + local oldSize,oldPresentation=Renderer.uiSize,Renderer.uiPresentation + local canvas=g.newCanvas(800,720,{format='rgba8',dpiscale=1});M.image=canvas + local checks=0;local function check(ok,why)checks=checks+1;assert(ok,why)end + g.push('all') + local ok,why=xpcall(function() + -- Bitmap coordinates keep integer cartridge scaling; a vector drawer that + -- resets origin() paints one actual output pixel, without enlarged texels. + Renderer.uiSize=function()return 160,144 end + Renderer.uiPresentation={x=0,y=0,scaleX=5,scaleY=5} + g.setCanvas(canvas);g.origin();g.setScissor();g.setShader();g.clear(0,0,0,1) + local native={draw=function() + check(g.getCanvas()==canvas,'native stack left the full-size output target') + g.setColor(1,1,1,1);g.rectangle('fill',2,4,1,1) + end} + local vector={draw=function() + g.origin();g.setColor(1,0,0,1);g.rectangle('fill',23,31,1,1) + end} + require('src.render.ScreenSpaceUI').drawNativeStack({}, + {states={native,vector}},1,nil,Renderer,{width=800,height=720},false) + g.setCanvas();local pixels=g.readbackTexture(canvas) + local r,green,b=pixels:getPixel(10,20) + check(r>.99 and green>.99 and b>.99,'native bitmap moved from cartridge pixel grid') + r,green,b=pixels:getPixel(14,24) + check(r>.99 and green>.99 and b>.99,'native bitmap scale changed') + r,green,b=pixels:getPixel(23,31) + check(r>.99 and green<.01 and b<.01,'vector drawer lost its output pixel coordinates') + r,green,b=pixels:getPixel(24,31) + check(r<.01 and green<.01 and b<.01,'one vector pixel enlarged through a low-resolution intermediate') + pixels:release() + end,debug.traceback) + g.pop();Renderer.uiSize=oldSize;Renderer.uiPresentation=oldPresentation + if not ok then error(why,0)end + return{passed=true,checks=checks,scope='Real GPU direct native/vector menu output pixels'},canvas +end +return M From ad00d3cf7db57746ea31cca7aae27777f433e3d1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:24:54 +0100 Subject: [PATCH 056/187] Draw native Gen2 animation objects through their actual palette view owner --- LICENSE-ROXAS2712.md | 88 +++++++++++++++++++ src/battle/BattleState.lua | 20 +++++ src/battle/Gen2AnimPlayer.lua | 67 +++++++------- src/battle/Gen2AnimView.lua | 108 +++++++++++++++++++++++ src/battle/WideBattle.lua | 2 + src/core/ModCompat.lua | 2 +- tests/engine/gen2_animation_objects.lua | 111 ++++++++++++++++++++++++ tests/gpu/gen2_animation_objects.lua | 80 +++++++++++++++++ 8 files changed, 444 insertions(+), 34 deletions(-) create mode 100644 LICENSE-ROXAS2712.md create mode 100644 src/battle/Gen2AnimView.lua create mode 100644 tests/engine/gen2_animation_objects.lua create mode 100644 tests/gpu/gen2_animation_objects.lua diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..fbcffd47c --- /dev/null +++ b/LICENSE-ROXAS2712.md @@ -0,0 +1,88 @@ +# Roxas2712 — Fork Contributions + +Version 1.0 · Effective 25 October 2026 + +Copyright © 2026 Roxas2712, to the extent of rights held. +All rights reserved except as expressly granted below or already granted. + +## 1. Scope and ownership + +This notice applies only to original contributions made by Roxas2712 +(GitHub: Roxas2712) to `Roxas2712/Gen2Recomped`, including original additions +within modified files, insofar as he owns the relevant rights. Git history, +pull-request diffs and source provenance identify contributions; a commit +author or a whole-file change alone does not establish ownership of every +part of that file. + +It does not claim ownership of Cedric's work, Gen1Recomp / BOIS CLUB GAMES +work, other contributors' work, third-party dependencies, or ROM-derived +content. Their applicable licences and notices remain in force. It does not +grant permission to distribute the complete combined fork where another +rights holder's permission is required. + +## 2. General permission and reserved rights + +For contributions covered by this notice and not already licensed more +broadly, you may read and study the published source and make private, +non-commercial modifications and builds for your own use. This grants no +general permission to redistribute, sublicense, sell, or offer those +contributions as a hosted service. Other uses require Roxas2712's +written permission, except for the specific permission below and rights +available independently under applicable law. + +Keep the relevant copyright, licence and attribution notices. Do not +represent Roxas2712's contributions as exclusively authored by someone +else. Public source availability alone does not put them in the public +domain or grant an open-source licence. + +## 3. Specific permission for Cedric / UNDERdecoded + +Roxas2712 expressly grants Cedric (Ced; GitHub: UNDERdecoded / +UNDERdecodedHD) a non-exclusive, worldwide, perpetual, royalty-free +permission to use, reproduce, integrate, modify and distribute the covered +contributions that Roxas2712 submits or expressly offers through pull +requests to `UNDERdecoded/Gen2Recomped`, for Cedric's Gen2Recomped / +RetroRecompiled project. This includes its renamed continuation maintained +by Cedric, its official builds, and its commercial operation. + +Cedric may license the incorporated contributions to recipients as part of +that project, subject to preserving this permission's applicable credit and +notice requirements. This permission is not a copyright assignment, is not +exclusive, and does not cover unpublished work, separate repositories, +ModCompass service operation, credentials, or content not offered in those +pull requests. Roxas2712 retains his own rights to his contributions. + +For uses relying on this permission: + +- retain the relevant source copyright and licence notices; +- give visible credit in the project's README or equivalent public project + page, and in the application's existing About / Credits view, if present; +- use at least the following credit, or a substantially equivalent one: + + > Contributions by Roxas2712 — + > https://github.com/Roxas2712 + +A maintained consolidated contributor credit is sufficient; a separate +credit on every screen or for every change is not required. Adapted +contributions must not be attributed exclusively to another author. Other +contributors' credits must also remain intact. + +## 4. Earlier licences and permissions remain effective + +This notice does not revoke, narrow, or retrospectively add conditions to +any permission already validly granted, including MIT / GPL grants, +separate agreements, or permissions arising from previously submitted +pull requests. Where an earlier grant permits a use independently of this +notice, its existing terms govern that use. For such earlier uses, the +visible credit above is requested to the extent it is not already required. + +For subsequent contributions, this is the default statement of Roxas2712's own reserved rights and the specific PR permission, subject to any +applicable existing licence obligations or separately agreed terms. It +does not change another rights holder's licence or guarantee compatibility +of a particular combination of code. + +## 5. No warranty + +To the extent permitted by applicable law, these contributions are provided +as is, without warranty. No support obligation, endorsement, or rights to +third-party code, game assets, names, or trademarks are granted. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 1bebafb9d..752860978 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1241,6 +1241,8 @@ local function newBattle(game) local ok, Gen2AnimPlayer = pcall(require, "src.battle.Gen2AnimPlayer") if ok then self.animPlayer = Gen2AnimPlayer.new(game.data) + self.animPlayer.battle = self.battle + self.animView = self.animPlayer.view end else local ok, AnimPlayer = pcall(require, "src.battle.AnimPlayer") @@ -2875,6 +2877,9 @@ function BattleState:updateQueue() ballIndex = item.ball and (self:itemDef(item.ball) or {}).index or nil, + ballPalette = GameVersion.isGen2() and item.ball + and self:ballPalette((self:itemDef(item.ball) or {}).key or item.ball) + or nil, ballFlicker = item.ball and self:ballFlicker(item.ball) or nil } or nil) @@ -6778,6 +6783,21 @@ function BattleState:animationsOn() return not o or o.animations ~= false end +-- GetBallAnimPal / BallColors. Native item IDs retain their registry key; +-- the public item spelling selects the palette on the actual queued throw. +-- Names/order follow Gen1Recomp's Gen2 BattleState and cartridge BallColors. +local GEN2_BALL_PALETTES = { + MASTER_BALL='PAL_BATTLE_OB_GREEN', ULTRA_BALL='PAL_BATTLE_OB_YELLOW', + GREAT_BALL='PAL_BATTLE_OB_BLUE', POKE_BALL='PAL_BATTLE_OB_RED', + HEAVY_BALL='PAL_BATTLE_OB_GRAY', LEVEL_BALL='PAL_BATTLE_OB_BROWN', + LURE_BALL='PAL_BATTLE_OB_BLUE', FAST_BALL='PAL_BATTLE_OB_BLUE', + FRIEND_BALL='PAL_BATTLE_OB_YELLOW', MOON_BALL='PAL_BATTLE_OB_GRAY', + LOVE_BALL='PAL_BATTLE_OB_RED', +} +function BattleState:ballPalette(itemId) + return GEN2_BALL_PALETTES[itemId] or 'PAL_BATTLE_OB_GRAY' +end + -- Drop the announcement-time move-anim row. Gen 1 queues PlayMoveAnimation -- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips -- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index 2a5075488..f2e82d662 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -133,7 +133,7 @@ end function Gen2AnimPlayer.new(data) local anims = data and data.battle_anims or {} - return setmetatable({ + local self = setmetatable({ data = data, anims = anims, images = {}, @@ -141,14 +141,19 @@ function Gen2AnimPlayer.new(data) warned = {}, objects = {}, effects = {}, + frames = 0, + tail = 0, -- WideBattle reads these two off the Gen 1 player; keep them defined steps = {}, stepIndex = 1, done = true, }, Gen2AnimPlayer) + self.view = require('src.battle.Gen2AnimView').new(anims, data) + return self end function Gen2AnimPlayer:release() + if self.view then self.view:release(); self.view = nil end for _, img in pairs(self.images) do if img and img.release then pcall(img.release, img) end end @@ -237,13 +242,16 @@ local function ballParam(self, opts) end function Gen2AnimPlayer:start(moveId, attackerIsPlayer, opts) + if self.presentation then self.presentation.retire() end + self.presentation = nil self.objects = {} self.effects = {} self.callStack = {} - self.vars = { var = 0, param = 0, loop = nil } + self.vars = { var = 0, param = opts and opts.param or 0, loop = nil } self.wait = 0 self.tail = 0 self.ranFrames = 0 + self.frames = 0 self.wobbles = 0 self.keepSprites = false self.keptSprites = nil @@ -818,6 +826,7 @@ function Gen2AnimPlayer:playCry() end function Gen2AnimPlayer:update() + self.frames = (self.frames or 0) + 1 if self.done then if self.tail > 0 then self.tail = self.tail - 1 end if self.tail <= 0 and not self.keepSprites then self.objects = {} end @@ -926,50 +935,42 @@ function Gen2AnimPlayer:finalSprites() return self.keptSprites end -function Gen2AnimPlayer:drawSprites(objects) - local g = love and love.graphics - if not g or not objects then return end - local shader = require("src.render.PaletteFX").shader() - for _, obj in ipairs(objects) do +-- Project the actual current OAM rows. This is also the input to the native +-- object renderer; readers never advance frames or own the VM's objects. +function Gen2AnimPlayer:oam(objects) + local out = {} + for _, obj in ipairs(objects or self.objects) do local set = frameset(self, obj) local frame = set and set.frames[obj.frame] - -- an oamwait row holds the object in place with nothing drawn - local row = frame and frame.oam and self.anims.oam - and self.anims.oam[frame.oam] + local row = frame and frame.oam and self.anims.oam and self.anims.oam[frame.oam] if row then - local img = self:sheetImage(obj.def.gfx) - local pal = self:paletteFor(obj.def.palette or 0) - if img and shader and g.setShader then - g.setShader(shader) - shader:send("c0", pal[1]) - shader:send("c1", pal[2]) - shader:send("c2", pal[3]) - shader:send("c3", pal[4]) - end - -- BattleAnimOAMUpdate keeps one flip state per object per frame - -- ($CA19 = frameFlags XOR obj.oamFlags, masked $E0); it negates the - -- sprite offsets and is then XORed into each sprite's own attribute. local flip = band(bxor((frame.xflip and 0x20 or 0) - + (frame.yflip and 0x40 or 0), - obj.def.oamFlags or 0), 0xE0) + + (frame.yflip and 0x40 or 0), + obj.def.oamFlags or 0), 0xE0) local xf = math.floor(flip / 32) % 2 == 1 local yf = math.floor(flip / 64) % 2 == 1 + local palette = obj.def.palette or 0 + if (obj.fn == 'PokeBall' or obj.fn == 'PokeBallBlocked') + and self.opts and self.opts.ballPalette then palette = self.opts.ballPalette end for _, sprite in ipairs(row.sprites) do local ox = obj.x + obj.xOffset + (xf and -sprite.x - 8 or sprite.x) local oy = obj.y + obj.yOffset + (yf and -sprite.y - 8 or sprite.y) - local quad = img and self:tileQuad(obj.def.gfx, row.tile + sprite.tile) - if quad and ox > 0 and ox < 168 and oy > 0 and oy < 160 then - local attr = bxor(sprite.attr, flip) - local sx = math.floor(attr / 32) % 2 == 1 - local sy = math.floor(attr / 64) % 2 == 1 - g.draw(img, quad, - ox - 8 + (sx and 8 or 0), oy - 16 + (sy and 8 or 0), 0, - sx and -1 or 1, sy and -1 or 1) + if ox > 0 and ox < 168 and oy > 0 and oy < 160 then + out[#out + 1] = {x=ox, y=oy, tile=row.tile + sprite.tile, + attr=bxor(sprite.attr, flip), palette=palette, gfx=obj.def.gfx} end end - if img and shader and g.setShader then g.setShader() end end end + return out +end + +function Gen2AnimPlayer:drawSprites(objects) + local View = require('src.battle.Gen2AnimView') + self.view = self.view or View.new(self.anims, self.data) + self.presentation = self.presentation or View.runner(self) + self.presentation.selectObjects(objects) + return self.view:drawObjects(self.presentation, self.battle) end return Gen2AnimPlayer diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua new file mode 100644 index 000000000..667c23c68 --- /dev/null +++ b/src/battle/Gen2AnimView.lua @@ -0,0 +1,108 @@ +-- Native object drawing over Gen2AnimPlayer's actual OAM projection. +-- Object layout/flip math retains Gen2AnimPlayer provenance (Cedric); +-- public owner integration by Roxas2712, see NOTICE.md and contribution licence. +local Palette = require('src.render.Gen2PaletteDraw') +local PaletteFX = require('src.render.PaletteFX') +local View = {} +View.__index = View +local slots = { + PAL_BATTLE_OB_ENEMY=0, PAL_BATTLE_OB_PLAYER=1, PAL_BATTLE_OB_GRAY=2, + PAL_BATTLE_OB_YELLOW=3, PAL_BATTLE_OB_RED=4, PAL_BATTLE_OB_GREEN=5, + PAL_BATTLE_OB_BLUE=6, PAL_BATTLE_OB_BROWN=7, +} +function View.new(anims, data) + return setmetatable({data=anims or {}, gameData=data or {}, images={}, quads={}}, View) +end +function View:image(path) + if not path then return nil end + if self.images[path] == nil then + local ok, image = pcall(love.graphics.newImage, path) + if ok and image and image.setFilter then image:setFilter('nearest','nearest') end + self.images[path] = ok and image or false + end + return self.images[path] or nil +end +function View:release() + for _, image in pairs(self.images) do + if image and image.release then image:release() end + end + self.images, self.quads = {}, {} +end +function View:objPalette(name, battle) + local index = slots[name] or tonumber(name) + if index == 0 or index == 1 then + local mon = battle and battle[index == 0 and 'enemy' or 'player'] + mon = mon and (mon.mon or mon) + if mon then + return PaletteFX.monPal(self.gameData, mon.species, nil, + require('src.pokemon.Pokemon').isShiny(mon)) or PaletteFX.GRAYS + end + end + return self.data.palettes and self.data.palettes[(index or 2)-2] or PaletteFX.GRAYS +end +function View:quad(gfx, tile, image) + local sheet = self.data.gfx and self.data.gfx[gfx] + if not sheet or tile < 0 or tile >= sheet.tiles then return nil end + local bySheet = self.quads[gfx] or {}; self.quads[gfx] = bySheet + if not bySheet[tile] then + local w,h = image:getDimensions() + local cols = sheet.cols or 16 + bySheet[tile] = love.graphics.newQuad((tile%cols)*8, math.floor(tile/cols)*8, 8,8,w,h) + end + return bySheet[tile] +end +function View:drawObjects(runner, battle) + local g = love and love.graphics + if not g then return end + g.push('all') + local ok, err = pcall(function() + g.setColor(1,1,1,1) + local active + for _, obj in ipairs(runner:oam()) do + local sheet = self.data.gfx and self.data.gfx[obj.gfx] + local image = sheet and self:image(sheet.image) + local quad = image and self:quad(obj.gfx, obj.tile, image) + if quad then + local colors = self:objPalette(obj.palette, battle) + if colors ~= active then + if not Palette.useRaw(Palette.resolve(colors)) then g.setShader() end + active = colors + end + local attr = obj.attr or 0 + local sx = math.floor(attr/32)%2 == 1 and -1 or 1 + local sy = math.floor(attr/64)%2 == 1 and -1 or 1 + g.draw(image,quad,obj.x-8+(sx<0 and 8 or 0),obj.y-16+(sy<0 and 8 or 0),0,sx,sy) + end + end + end) + g.pop() + if not ok then error(err,0) end +end + +-- Stable per-animation observations of the native VM, including its real +-- object state machine and frame clock. Filtered OAM proxies still delegate +-- to this same draw owner. Restarting the VM retires the old observation. +function View.runner(player) + local selected, frozen + local function state() + local structs = {} + for _, obj in ipairs(selected or player.objects) do + local fn = obj.fn and ('BATTLE_ANIM_FUNC_'..obj.fn:gsub('(%l)(%u)','%1_%2'):upper()) + if obj.fn == 'PokeBall' then fn = 'BATTLE_ANIM_FUNC_POKEBALL' end + if obj.fn == 'PokeBallBlocked' then fn = 'BATTLE_ANIM_FUNC_POKEBALL_BLOCKED' end + structs[#structs+1] = {index=obj.index,func=fn,jt=obj.state} + end + return {frames=player.frames or 0,var=player.vars and player.vars.var or 0, + stopped=player:isDone(),keepSprites=player.keepSprites, + objects={structs=structs}, oam=player:oam(selected)} + end + local runner = {} + function runner:oam() return frozen and frozen.oam or player:oam(selected) end + function runner.selectObjects(objects) selected=objects end + function runner.retire() frozen=state(); player=nil end + return setmetatable(runner, { + __index=function(_,key) return (frozen or state())[key] end, + __newindex=function() error('native Gen2 animation observation is read-only',2) end, + }) +end +return View diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index b8a4a4ca5..76e31c71f 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -430,10 +430,12 @@ end local function currentAnimationSprites(battle) if battle.animPlaying and battle.animPlayer then + if battle.animPlayer.oam then return battle.animPlayer:oam() end local step = battle.animPlayer.steps[battle.animPlayer.stepIndex] return step and step.sprites end if battle.lockedBall and battle.animPlayer then + if battle.animPlayer.oam then return battle.animPlayer:oam(battle.lockedBall) end return battle.lockedBall end end diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 1ccf52cb4..d651dc3c7 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -34,7 +34,6 @@ -- party follower -- src.ui.gen2.Chrome no shared window-chrome module -- src.ui.gen2.SaveMenu the save flow is part of StartMenu here --- src.ui.gen2.BattleAnimView Gen2AnimPlayer is a player, not a view -- src.battle.UIVisibility no equivalent -- src.ui.PartyPanel no equivalent -- src.battle.gen2.Battle AMBIGUOUS, which is the same as absent here. @@ -62,6 +61,7 @@ ModCompat.ALIASES = { ["src.world.gen2.Player"] = "src.world.Player", ["src.world.gen2.World"] = "src.world.OverworldController", ["src.world.gen2.Map"] = "src.world.Map", + ["src.ui.gen2.BattleAnimView"] = "src.battle.Gen2AnimView", ["src.world.gen2.Permissions"] = "src.world.Collision", ["src.world.gen2.Palettes"] = "src.render.PaletteFX", diff --git a/tests/engine/gen2_animation_objects.lua b/tests/engine/gen2_animation_objects.lua new file mode 100644 index 000000000..e314757a9 --- /dev/null +++ b/tests/engine/gen2_animation_objects.lua @@ -0,0 +1,111 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Player=require('src.battle.Gen2AnimPlayer') +local View=require('src.battle.Gen2AnimView') +local Battle=require('src.battle.BattleState') +local Wide=require('src.battle.WideBattle') +local oldVersion=Version.get() +local function fixture() + return {battle_anims={gen2=true,moveAnims={FIX_MOVE=1},misc={throwPokeBall=1}, + scripts={[1]={entry=1,code={{op='obj',args={0,40,40,0}},{op='delay',frames=5},{op='ret'}}}}, + objects={[0]={oamFlags=0,fixY=255,frameset=0,fn=0,palette=4,gfx=2}},functions={[0]='Null'}, + framesets={[0]={frames={{oam=0,duration=20}},loop='hold'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0},{x=8,y=0,tile=1,attr=32}}}}, + gfx={[2]={tiles=2,cols=2,image='fixture.png'}},palettes={[2]={{255,255,255},{200,100,50},{100,30,10},{0,0,0}}}}} +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local data=fixture();local p=Player.new(data) + T.eq(p:isDone(),true,'new native player has a terminal idle state') + T.eq(getmetatable(p.view),View,'VM owns the actual public drawing class') + T.eq(p:start('FIX_MOVE',true,{param=17}),true,'native bytecode starts normally') + T.eq(p.vars.param,17,'native script receives its caller parameter') + p:update() + local native=p.objects[1] + local rows=p:oam() + T.eq(#rows,2,'actual frame OAM contains both object tiles') + T.same(rows[1],{x=40,y=40,tile=0,attr=0,palette=4,gfx=2},'first native sprite projection') + T.same(rows[2],{x=48,y=40,tile=1,attr=32,palette=4,gfx=2},'second native sprite retains tile and flip') + local runner=View.runner(p) + T.eq(runner.frames,1,'runner observes actual update clock') + T.eq(runner.objects.structs[1].index,native.index,'runner observes actual native object identity') + T.eq(runner.objects.structs[1].jt,native.state,'runner observes actual object state machine') + local age,frame=native.age,p.frames + runner:oam();runner:oam() + T.eq(native.age,age,'OAM reads cannot tick object motion') + T.eq(p.frames,frame,'OAM reads cannot tick script time') + p:update();T.eq(runner.frames,2,'delay frames count on actual native timeline') + data.battle_anims.framesets[0].frames[1].xflip=true + rows=p:oam() + T.eq(rows[1].x,32,'object X flip negates offset and eight-pixel cell width') + T.eq(rows[1].attr,32,'object X flip reaches sprite attributes') + T.eq(rows[2].attr,0,'object flip XORs a sprite already flipped') + data.battle_anims.framesets[0].frames[1].yflip=true + T.eq(p:oam()[1].y,32,'object Y flip negates native row offset') + native.fn,native.state='PokeBall',5 + p.opts.ballPalette='TEST_BALL' + T.eq(runner.objects.structs[1].func,'BATTLE_ANIM_FUNC_POKEBALL','source reads the actual native ball function') + T.eq(runner.objects.structs[1].jt,5,'source reads real landed ball state') + T.eq(p:oam()[1].palette,'TEST_BALL','typed native ball palette follows actual object') + p.vars.var,p.keepSprites=1,true + T.eq(runner.var,1,'capture result observation uses VM variable') + T.eq(runner.keepSprites,true,'capture retention observation uses VM flag') + p.presentation=runner + local ticks=runner.frames + p:start('FIX_MOVE',false) + T.eq(runner.frames,ticks,'restarting VM cannot rebind previous animation observation') + T.eq(runner:oam()[1].palette,'TEST_BALL','retired OAM retains its own throw') + T.eq(p.frames,0,'new animation starts with its own actual clock') + p:update() + local g=love.graphics;local draw=g.draw;local calls={} + g.draw=function(...)calls[#calls+1]={...}end + local shader={};g.setShader(shader);g.setColor(.2,.3,.4,.5) + p:draw() + T.eq(#calls,2,'native VM draws through the actual object-view owner') + T.eq(g.getShader(),shader,'object pass restores caller shader') + T.same({g.getColor()},{.2,.3,.4,.5},'object pass restores caller tint') + g.draw=function()error('actual object draw failure')end + T.raises(function()p:draw()end,'actual object draw failure','render error remains observable') + T.eq(g.getShader(),shader,'failed object pass restores caller state') + g.draw=draw;g.setShader();g.setColor(1,1,1,1) + p:release() +end +require('src.core.ModCompat').install() +T.eq(require('src.ui.gen2.BattleAnimView'),View,'compat spelling resolves actual native renderer class') +do + Version.set('crystal') + local data=T.fixtures.fresh();data.constants.gen=2 + data.battle_anims=fixture().battle_anims + data.items.ITEM_FIX_BALL={id='ITEM_FIX_BALL',key='POKE_BALL',index=5} + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + T.eq(s.animView,s.animPlayer.view,'screen exposes the same actual drawing owner') + local item + s.ballPalette=function(self,id) item=id;return 'TEST_QUEUED_BALL'end + s.queue={{anim='TOSS_ANIM',attackerIsPlayer=true,ball='ITEM_FIX_BALL',caught=true,shakes=3,animDelayed=true}} + s.current,s.waitFrames=nil,nil + s:updateQueue() + T.eq(item,'POKE_BALL','actual queued throw calls public palette seam with source item spelling') + T.eq(s.animPlayer.opts.ballPalette,'TEST_QUEUED_BALL','native bytecode owns the selected thrown palette') + T.eq(s.animPlayer.opts.ball,'ITEM_FIX_BALL','native inventory item remains unchanged') + T.eq(s.animPlayer.battle,s.battle,'renderer sees live native battle model') + local PaletteFX=require('src.render.PaletteFX') + local nativePal=PaletteFX.monPal + local observed + PaletteFX.monPal=function(_,species)observed=species;return PaletteFX.GRAYS end + s.animView:objPalette('PAL_BATTLE_OB_ENEMY',s) + T.eq(observed,s.enemy.mon.species,'native battler record resolves its actual Pokemon palette') + s.animView:objPalette('PAL_BATTLE_OB_PLAYER',{player=s.player.mon}) + T.eq(observed,s.player.mon.species,'direct native Pokemon model resolves the same palette owner') + PaletteFX.monPal=nativePal + s.animPlayer:update() + local layer=s.drawAnimLayer;local layers=0 + s.drawAnimLayer=function(self,...)layers=layers+1;return layer(self,...)end + Wide.draw(s) + T.eq(layers,1,'wide native composition draws actual Gen2 OAM without Gen1 frame blocks') +end +Version.set(oldVersion) +T.finish('native Gen2 animation object owner, observation and queued ball palette') diff --git a/tests/gpu/gen2_animation_objects.lua b/tests/gpu/gen2_animation_objects.lua new file mode 100644 index 000000000..e33ff7961 --- /dev/null +++ b/tests/gpu/gen2_animation_objects.lua @@ -0,0 +1,80 @@ +-- Native Gen2 OAM/view pixels. All texture bytes below are generated test art. +local M={} +function M.run() + local Version=require('src.core.GameVersion') + local Palette=require('src.render.PaletteFX') + local Player=require('src.battle.Gen2AnimPlayer') + local g=love.graphics + local before={version=Version.get(),mode=Palette.mode,ramp=Palette.customRamp()} + local checks,frames=0,0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local colors={{255,255,255},{220,40,80},{70,160,30},{0,0,0}} + local custom={{240,230,220},{180,150,120},{100,70,40},{20,10,5}} + local atlas=love.image.newImageData(16,8,'rgba8') + for y=0,7 do for x=0,15 do + local shade=(x+2*y)%4 + local gray=(3-shade)/3 + atlas:setPixel(x,y,gray,gray,gray,shade==0 and 0 or 1) + end end + local path='qa-native-oam.png';atlas:encode('png',path);atlas:release() + local canvas=g.newCanvas(160,144,{format='rgba8',dpiscale=1});M.image=canvas + local sentinel=g.newShader([[vec4 effect(vec4 tint,Image image,vec2 uv,vec2 screen){return Texel(image,uv)*tint;}]]) + g.push('all') + local ok,why=xpcall(function() + for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + for _,mode in ipairs({'gbc','dmg','custom'})do + -- Each case owns both settings: setMode intentionally retains a saved + -- custom ramp, which would leak the previous edition's custom case. + Palette.setDisplay(mode,mode=='custom'and custom or nil) + local expected=mode=='gbc'and colors or mode=='custom'and custom or Palette.GRAYS + for _,enemy in ipairs({false,true})do for _,flip in ipairs({0,32,64,96})do + local xf,yf=flip%64>=32,flip>=64 + local data={battle_anims={gen2=true,moveAnims={FIX_MOVE=1}, + scripts={[1]={entry=1,code={{op='obj',args={0,40,40,0}},{op='delay',frames=5},{op='ret'}}}}, + objects={[0]={oamFlags=1,fixY=255,frameset=0,fn=0,palette=4,gfx=2}},functions={[0]='Null'}, + framesets={[0]={frames={{oam=0,duration=20,xflip=xf,yflip=yf}},loop='hold'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0},{x=8,y=0,tile=1,attr=32}}}}, + gfx={[2]={tiles=2,cols=2,image=path}},palettes={[2]=colors}}} + local player=Player.new(data);check(player:start('FIX_MOVE',not enemy),'native VM start failed') + player:update();frames=frames+1 + g.setCanvas(canvas);g.origin();g.setScissor();g.setShader();g.setBlendMode('alpha') + g.clear(.125,.25,.5,1);g.setColor(.1,.2,.3,.4);g.setShader(sentinel) + local clock,age=player.frames,player.objects[1].age + player:draw() + check(g.getShader()==sentinel,'native object pass lost caller shader') + local tint={g.getColor()};check(math.abs(tint[1]-.1)<.001 and math.abs(tint[4]-.4)<.001,'object pass lost caller tint') + check(player.frames==clock and player.objects[1].age==age,'object drawing advanced the native animation') + g.setCanvas();g.setShader() + local image=g.readbackTexture(canvas) + local ax,ay=enemy and 140 or 40,enemy and 80 or 40 + for tile=0,1 do + local left=ax+(xf and -tile*8-8 or tile*8)-8 + local top=ay+(yf and -8 or 0)-16 + local spriteXFlip=xf~=(tile==1) + for y=0,7 do for x=0,7 do + local sourceX=tile*8+(spriteXFlip and 7-x or x) + local sourceY=yf and 7-y or y + local shade=(sourceX+2*sourceY)%4 + local got={image:getPixel(left+x,top+y)} + local wanted=shade==0 and {.125,.25,.5}or expected[shade+1] + for channel=1,3 do + local value=shade==0 and wanted[channel]or wanted[channel]/255 + check(math.abs(got[channel]-value)<2/255, + edition..' '..mode..' native OAM flip/position/palette mismatch') + end + check(math.abs(got[4]-1)<2/255,'native OAM lost destination alpha') + end end + end + image:release();player:release() + end end + end + end + end,debug.traceback) + g.setCanvas();g.pop();sentinel:release() + Version.set(before.version);Palette.setDisplay(before.mode,before.ramp) + if not ok then error(why,0)end + return {passed=true,checks=checks,frames=frames, + scope='Generated ROM-free native VM/OAM/object renderer pixels: Gold/Silver/Crystal, both sides, all object flip combinations, per-sprite XOR, GBC/DMG/custom palettes, alpha and caller graphics state. Not whole-mod or OS-window acceptance.'},canvas +end +return M From 198e08865a5829c66062ccb53ba877d433140d57 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:42:52 +0100 Subject: [PATCH 057/187] Expose actual native Gen2 move and scene presentation entry points --- src/battle/BattleState.lua | 46 ++++++-- src/battle/Gen2AnimPlayer.lua | 2 + src/battle/Gen2AnimView.lua | 2 +- src/battle/Gen2AnimationIds.lua | 27 +++++ src/battle/Gen2BattleScene.lua | 134 ++++++++++++++++++++++++ src/battle/Gen2MovePresentation.lua | 66 ++++++++++++ src/battle/WideBattle.lua | 15 ++- src/import/RomExtractorGen2.lua | 1 + tests/engine/gen2_battle_scene.lua | 65 ++++++++++++ tests/engine/gen2_move_presentation.lua | 96 +++++++++++++++++ tests/gpu/gen2_battle_scene.lua | 68 ++++++++++++ 11 files changed, 509 insertions(+), 13 deletions(-) create mode 100644 src/battle/Gen2AnimationIds.lua create mode 100644 src/battle/Gen2BattleScene.lua create mode 100644 src/battle/Gen2MovePresentation.lua create mode 100644 tests/engine/gen2_battle_scene.lua create mode 100644 tests/engine/gen2_move_presentation.lua create mode 100644 tests/gpu/gen2_battle_scene.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 752860978..acbd5556b 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1243,6 +1243,7 @@ local function newBattle(game) self.animPlayer = Gen2AnimPlayer.new(game.data) self.animPlayer.battle = self.battle self.animView = self.animPlayer.view + self.anims = self.animPlayer.anims end else local ok, AnimPlayer = pcall(require, "src.battle.AnimPlayer") @@ -2741,12 +2742,19 @@ function BattleState:updateQueue() -- again. Dig/Fly's charge hide and a genuinely invulnerable battler -- are the two that must survive, same as in resetPicFx. self:restoreHiddenPics() + if GameVersion.isGen2() then + if self:startPendingAfterAnim() then return true end + if not self.animPlayer.keepSprites then self.anim = nil end + end -- the target's hit blink + damage sound follow the animation -- (pokered plays them after PlayMoveAnimation returns) if self.pendingHit then - self:applyHitFx(self.pendingHit) + if not (GameVersion.isGen2() and self.afterAnimPlayed) then + self:applyHitFx(self.pendingHit) + end self.pendingHit = nil end + if GameVersion.isGen2() then self.afterAnimPlayed = nil end end return true end @@ -2867,9 +2875,7 @@ function BattleState:updateQueue() self.waitFrames = math.max(self.waitFrames or 0, self.gen3Anim.total or 0) elseif self.animPlayer then - local ok, started = pcall(self.animPlayer.start, self.animPlayer, - item.anim, item.attackerIsPlayer, - (item.shakes or item.ball) + local opts = (item.shakes or item.ball) and { shakes = item.shakes, ball = item.ball, caught = item.caught, -- Gen 2's toss script branches on the @@ -2882,7 +2888,21 @@ function BattleState:updateQueue() or nil, ballFlicker = item.ball and self:ballFlicker(item.ball) or nil } - or nil) + or nil + local ok, started + if GameVersion.isGen2() then + if mdef and not BALL_ANIMS[item.anim] then + ok, started = pcall(self.animForMove, self, item.anim, + item.attackerIsPlayer and 'player' or 'enemy', + item.animParam, item.effectiveness, item.afterAnim) + else + ok, started = pcall(require('src.battle.Gen2MovePresentation').start, + self, item.anim, item.attackerIsPlayer, opts) + end + else + ok, started = pcall(self.animPlayer.start, self.animPlayer, + item.anim, item.attackerIsPlayer, opts) + end -- a player that reports no data for the move (Gen 2 returns -- false) falls through to the single-sound path below self.animPlaying = ok and started ~= false @@ -2951,11 +2971,13 @@ function BattleState:updateQueue() -- plays each row's MoveSoundTable entry with its pitch/tempo -- modifiers); which side the pic effects target follows the -- attacker (hWhoseTurn) - self.animName = item.anim - self.animAttackerIsPlayer = item.attackerIsPlayer - self:resetPicFx() - for _, ev in ipairs(self.animPlayer:pollEffects()) do - self:applyAnimEffect(ev) -- frame-0 rows (first sound/effect) + if not GameVersion.isGen2() then + self.animName = item.anim + self.animAttackerIsPlayer = item.attackerIsPlayer + self:resetPicFx() + for _, ev in ipairs(self.animPlayer:pollEffects()) do + self:applyAnimEffect(ev) -- frame-0 rows (first sound/effect) + end end self.pendingHit = item.hit else @@ -12892,6 +12914,7 @@ function BattleState:drawBattleField() end function BattleState:drawClassic() + if GameVersion.isGen2() then return self:drawScene() end -- AskName: ClearSprites + wild ClearScreenArea -- white field under the -- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top. if self.blankForAskName then @@ -13005,4 +13028,7 @@ function BattleState:drawClassic() end end +require('src.battle.Gen2MovePresentation').install(BattleState) +require('src.battle.Gen2BattleScene').install(BattleState) + return BattleState diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index f2e82d662..bc19c78cc 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -133,6 +133,7 @@ end function Gen2AnimPlayer.new(data) local anims = data and data.battle_anims or {} + require('src.battle.Gen2AnimationIds').install(anims) local self = setmetatable({ data = data, anims = anims, @@ -196,6 +197,7 @@ local GEN1_BALL_ANIMS = { function Gen2AnimPlayer:scriptFor(moveId) local anims = self.anims local id = anims.moveAnims and anims.moveAnims[moveId] + or anims.ids and anims.ids[moveId] if not id then local alias = GEN1_BALL_ANIMS[moveId] if alias == false then return nil end diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua index 667c23c68..2c45a074b 100644 --- a/src/battle/Gen2AnimView.lua +++ b/src/battle/Gen2AnimView.lua @@ -92,7 +92,7 @@ function View.runner(player) if obj.fn == 'PokeBallBlocked' then fn = 'BATTLE_ANIM_FUNC_POKEBALL_BLOCKED' end structs[#structs+1] = {index=obj.index,func=fn,jt=obj.state} end - return {frames=player.frames or 0,var=player.vars and player.vars.var or 0, + return {animId=player.moveId,frames=player.frames or 0,var=player.vars and player.vars.var or 0, stopped=player:isDone(),keepSprites=player.keepSprites, objects={structs=structs}, oam=player:oam(selected)} end diff --git a/src/battle/Gen2AnimationIds.lua b/src/battle/Gen2AnimationIds.lua new file mode 100644 index 000000000..9d3c15190 --- /dev/null +++ b/src/battle/Gen2AnimationIds.lua @@ -0,0 +1,27 @@ +-- GSC constants/move_constants.asm, also documented by Gen1Recomp's +-- RomExtractorGen2 BATTLE_ANIM_IDS. These name existing native ROM programs; +-- they neither synthesize scripts nor replace cartridge data. +local Ids = { + ANIM_SWEET_SCENT_2=0xff, ANIM_THROW_POKE_BALL=0x100, + ANIM_SEND_OUT_MON=0x101, ANIM_RETURN_MON=0x102, + ANIM_CONFUSED=0x103, ANIM_SLP=0x104, ANIM_BRN=0x105, + ANIM_PSN=0x106, ANIM_SAP=0x107, ANIM_FRZ=0x108, + ANIM_PAR=0x109, ANIM_IN_LOVE=0x10a, ANIM_IN_SANDSTORM=0x10b, + ANIM_IN_NIGHTMARE=0x10c, ANIM_IN_WHIRLPOOL=0x10d, + ANIM_MISS=0x10e, ANIM_ENEMY_DAMAGE=0x10f, + ANIM_ENEMY_STAT_DOWN=0x110, ANIM_PLAYER_STAT_DOWN=0x111, + ANIM_PLAYER_DAMAGE=0x112, ANIM_WOBBLE=0x113, + ANIM_SHAKE=0x114, ANIM_HIT_CONFUSION=0x115, +} +local M={} +function M.install(anims) + if not (anims and anims.gen2) then return anims end + local ids=anims.ids or {};anims.ids=ids + for name,index in pairs(Ids) do + if ids[name]==nil and anims.scripts and anims.scripts[index] then + ids[name]=index + end + end + return anims +end +return M diff --git a/src/battle/Gen2BattleScene.lua b/src/battle/Gen2BattleScene.lua new file mode 100644 index 000000000..9f93267c5 --- /dev/null +++ b/src/battle/Gen2BattleScene.lua @@ -0,0 +1,134 @@ +-- Native classic composition retained from src/battle/BattleState.lua. +-- The Gen2 scene-owner split reuses its real panels, FX and OAM; it does not +-- own battle decisions or animation time. Existing source provenance remains. +local Timing=require('src.core.Timing') +local Runtime=require('src.mods.Runtime') +local M={} +function M.install(Screen) + function Screen:drawPanel() + -- AskName: ClearSprites + wild ClearScreenArea -- white field under the + -- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top. + if self.blankForAskName then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + return + end + local fx = self.fx + -- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake); + -- the animations-off fallback keeps the old +-2 alternation + local sx = (fx and fx.shakeX) or 0 + local sy = (fx and fx.shakeY) or 0 + if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then + sx = self.frame % 4 < 2 and 2 or -2 + end + -- intro slide-in offset: 2 px per frame, so 144 px over 72 frames + local slide = (self.introSlide or 0) * Timing.BATTLE_SLIDE_PX_PER_FRAME + + if self:colorMode() then + -- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the + -- BGP fade -> mon pics -> OAM anim sprites (never BGP-faded) + local g = love.graphics + local wavy = fx and fx.wavy + -- push("all")/pop restores the bound canvas, a mod's depth set included + g.push("all") + g.setCanvas(self.bgCanvas) + -- The white paper the whole battle is drawn on -- unless something has + -- put a WORLD where the paper goes, in which case painting it is what + -- hides the world. Clearing to transparent instead leaves only what is + -- actually drawn (HUDs, boxes, pics) and lets the 3D frame underneath + -- show between them. The zone shader passes alpha through unchanged + -- (PaletteFX.shader: `vec4(mapped, p.a)`), so transparency survives the + -- colour pass rather than being remapped to a palette shade. + if self:worldBackdrop() then + g.clear(0, 0, 0, 0) + else + g.setColor(1, 1, 1, 1) + g.rectangle("fill", 0, 0, 160, 144) + end + g.setColor(1, 1, 1, 1) + -- the party ball rows are OBJ sprites on hardware (see drawBallRow): + -- collect them here and draw them over the finished zone pass instead + local balls = self:gen2BallColors() and {} or nil + self.deferBalls = balls + self:drawHUDs(slide) + self.deferBalls = nil + self:drawTextArea() + if wavy then + -- the mon pics are BG tiles on the GB, so SE_WAVY_SCREEN bends + -- them too: bake them into the canvas as DMG grays and let the + -- zone pass color them by region (exactly what the SGB did) + self.grayPics = true + g.setScissor(0, 0, 160, 96) -- BG pics live above the text box + self:drawPicsLayer(slide, 0, 0) + g.setScissor() + self.grayPics = nil + end + g.pop() + self:drawZonePass(self:applyWavy(self.bgCanvas), sx, sy) + if balls and balls[1] then + -- they ride the window shake with the HUD chrome they sit in + local shifted = sx ~= 0 or sy ~= 0 + if shifted then g.push() g.translate(sx, sy) end + g.setColor(1, 1, 1, 1) + for _, row in ipairs(balls) do + self:drawBallRow(row[1], row[2], row[3], row[4]) + end + if shifted then g.pop() end + end + if not wavy then + -- the pics are BG tiles in rows 0-11 on the GB: they can never + -- cover the text box, whatever the SE offsets do (a vertical + -- window shake moves the box down with everything else) + g.setScissor(0, 0, 160, 96 + math.max(0, sy)) + self:drawPicsLayer(slide, sx, sy) + g.setScissor() + end + else + -- flat fallback (headless / no shader support): pre-colorized pics + -- on white, no palette fades -- and the same exception as above, so the + -- two paths agree about when there is paper to paint + love.graphics.setColor(1, 1, 1, 1) + if not self:worldBackdrop() then + love.graphics.rectangle("fill", 0, 0, 160, 144) + end + local shaking = sx ~= 0 or sy ~= 0 + if shaking then + love.graphics.push() + love.graphics.translate(sx, sy) + end + self:drawPicsLayer(slide, 0, 0) + self:drawHUDs(slide) + self:drawTextArea() + if shaking then + love.graphics.pop() + end + end + end + + -- panelFn replaces the BG panel; native wide layout also supplies its + -- translated OAM pass and surface dimensions through the same real site. + function Screen:drawSceneBody(panelFn,objectsFn,width,height) + local g=love.graphics + g.push('all') + local ok,why=pcall(function() + if panelFn then panelFn()else self:drawPanel()end + if self.blankForAskName then return end + if objectsFn then objectsFn()else self:drawAnimLayer(self:colorMode())end + local fx=self.fx + if fx and fx.flash and fx.flash>0 and self.frame%4<2 then + g.setShader();g.setColor(1,1,1,.85) + g.rectangle('fill',0,0,width or 160,height or 144) + end + end) + g.pop() + if not ok then error(why,0)end + end + function Screen:drawScene(bodyFn) + if bodyFn then bodyFn()else self:drawSceneBody()end + love.graphics.setColor(1,1,1,1) + if Runtime.wantsHook('battle.overlay')then + Runtime.call('battle.overlay',function()end,self) + end + end +end +return M diff --git a/src/battle/Gen2MovePresentation.lua b/src/battle/Gen2MovePresentation.lua new file mode 100644 index 000000000..bd3ef9087 --- /dev/null +++ b/src/battle/Gen2MovePresentation.lua @@ -0,0 +1,66 @@ +-- Native Gen2 presentation entry points over the existing battle queue/VM. +-- Public side/after-animation names follow Gen1Recomp's Gen2 BattleState; +-- battle decisions, damage, PP, queue consumption and time stay native. +local Version=require('src.core.GameVersion') +local View=require('src.battle.Gen2AnimView') +local M={} + +function M.start(screen,id,isPlayer,opts) + local player=screen.animPlayer + if not (Version.isGen2() and player and player.anims.gen2 + and player:scriptFor(id)) then return false end + if player:start(id,isPlayer,opts)==false then return false end + screen.animPlaying=true + screen.animName,screen.animAttackerIsPlayer=id,isPlayer + screen:resetPicFx() + player.presentation=View.runner(player) + screen.anim=player.presentation + for _,event in ipairs(player:pollEffects())do screen:applyAnimEffect(event)end + return true +end + +function M.install(Screen) + function Screen:turnFor(side) return side=='enemy' and 1 or 0 end + function Screen:afterAnimFor(side,kind) + if kind=='statdown'then + return side=='player'and 'ANIM_ENEMY_STAT_DOWN'or 'ANIM_WOBBLE' + end + if kind=='damage'then + return side=='player'and 'ANIM_ENEMY_DAMAGE'or 'ANIM_PLAYER_DAMAGE' + end + end + function Screen:playHitSound(effectiveness) + if not effectiveness or effectiveness==0 then return end + local name=effectiveness>10 and 'Super_Effective' + or effectiveness<10 and 'Not_Very_Effective'or 'Damage' + require('src.core.Sound').play(self.data,name) + end + function Screen:animForMove(moveId,side,param,effectiveness,afterAnim) + local player=self.animPlayer + if not (Version.isGen2() and self:animationsOn() and player + and player.anims.moveAnims and player.anims.moveAnims[moveId]) then return false end + local started=M.start(self,moveId,side~='enemy',{param=param or 0}) + if started then + local name=self:afterAnimFor(side,afterAnim) + self.pendingAfterAnim=name and {name=name,side=side, + effectiveness=afterAnim=='damage'and effectiveness or nil}or nil + self.afterAnimPlayed=nil + end + return started + end + function Screen:animForId(id,side,param) + return M.start(self,id,side~='enemy',{param=param or 0}) + end + function Screen:startPendingAfterAnim() + local pending=self.pendingAfterAnim + if not pending then return false end + self.pendingAfterAnim=nil + if self:animForId(pending.name,pending.side)then + self:playHitSound(pending.effectiveness) + self.afterAnimPlayed=true + return true + end + return false + end +end +return M diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 76e31c71f..0b3b50889 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -449,7 +449,7 @@ local function drawAnimationLayer(battle) end -- The whole 304x144 composition for one frame. -function WideBattle.draw(battle) +local function drawPanel(battle,withObjects) local g = love.graphics -- The field is the display mode's paper. Under a forced-mono mode the -- whole surface is remapped downstream (WideBattle.zones), so the field @@ -505,9 +505,20 @@ function WideBattle.draw(battle) g.pop() end shaken(function() drawHUDs(battle, slide) end) - drawAnimationLayer(battle) + if withObjects then drawAnimationLayer(battle) end shaken(function() drawTextArea(battle) end) +end + +function WideBattle.draw(battle) + if require('src.core.GameVersion').isGen2() then + return battle:drawScene(function() + battle:drawSceneBody(function()drawPanel(battle,false)end, + function()drawAnimationLayer(battle)end,WideBattle.WIDTH,WideBattle.HEIGHT) + end) + end + drawPanel(battle,true) + local g,fx=love.graphics,battle.fx if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then g.setColor(1, 1, 1, 0.85) g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT) diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..487c5add3 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -15175,6 +15175,7 @@ function RomExtractorGen2:gen2BattleAnims() -- (constants/move_constants.asm: ANIM_THROW_POKE_BALL $100, -- ANIM_SEND_OUT_MON $101, ANIM_IN_SANDSTORM $10b) misc = { throwPokeBall = 256, sendOutMon = 257, inSandstorm = 267 }, + ids = require('src.battle.Gen2AnimationIds').install({gen2=true,scripts=scripts}).ids, move = {}, effects = {}, } end diff --git a/tests/engine/gen2_battle_scene.lua b/tests/engine/gen2_battle_scene.lua new file mode 100644 index 000000000..5cf2d0ae4 --- /dev/null +++ b/tests/engine/gen2_battle_scene.lua @@ -0,0 +1,65 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Wide=require('src.battle.WideBattle') +local Runtime=require('src.mods.Runtime') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local before=Version.get() +local wants,call=Runtime.wantsHook,Runtime.call +local overlays,expected=0 +Runtime.wantsHook=function(name)if name=='battle.overlay'then return true end;return wants(name)end +Runtime.call=function(name,vanilla,screen,...) + if name=='battle.overlay'then overlays=overlays+1;T.eq(screen,expected,'overlay receives actual native screen');return vanilla(screen)end + return call(name,vanilla,screen,...) +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local data=T.fixtures.fresh();require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + expected=s + local layers={};local scene,body=s.drawScene,s.drawSceneBody + s.drawScene=function(self,...)layers[#layers+1]='scene';return scene(self,...)end + s.drawSceneBody=function(self,...)layers[#layers+1]='body';return body(self,...)end + s.drawPicsLayer=function()layers[#layers+1]='pics'end + s.drawHUDs=function()layers[#layers+1]='hud'end + s.drawTextArea=function()layers[#layers+1]='text'end + s.drawAnimLayer=function()layers[#layers+1]='objects'end + s.colorMode=function()return false end + local count=overlays;s:draw() + T.same(layers,{'scene','body','pics','hud','text','objects'},'actual classic draw reaches native scene/BG/OAM owners once') + T.eq(overlays,count+1,'actual scene emits one final overlay') + layers={};count=overlays + s:drawSceneBody(function()layers[#layers+1]='custom'end) + T.same(layers,{'body','custom','objects'},'replacement panel retains native object pass') + T.eq(overlays,count,'scene body does not emit duplicate final overlay') + layers={};s:drawScene(function()s:drawSceneBody(function()layers[#layers+1]='supplied'end)end) + T.same(layers,{'scene','body','supplied','objects'},'supplied scene body stays inside actual outer owner') + layers={};s.blankForAskName=true;s:drawSceneBody() + T.same(layers,{'body'},'native naming blank suppresses battle pictures and objects') + s.blankForAskName=nil + local g=love.graphics;local sentinel={};g.setShader(sentinel);g.setColor(.2,.3,.4,.5) + T.raises(function()s:drawSceneBody(function()g.setShader();g.setColor(0,0,0,1);error('panel draw failed')end)end,'panel draw failed','native scene propagates panel error') + T.eq(g.getShader(),sentinel,'failed panel restores graphics owner') + T.same({g.getColor()},{.2,.3,.4,.5},'failed panel restores caller tint') + g.setShader();g.setColor(1,1,1,1) + local nativeBody=s.drawSceneBody;local widths={};layers={};count=overlays + s.drawSceneBody=function(self,panel,objects,w,h) + widths={w,h};return nativeBody(self,panel,objects,w,h) + end + Wide.draw(s) + T.same(widths,{304,144},'actual native widescreen enters same composition owner with real surface') + T.eq(layers[1],'scene','wide layout enters native outer scene') + T.eq(layers[2],'body','wide layout enters native BG/OAM scene') + T.eq(overlays,count+1,'wide layout emits one final overlay') + -- Red keeps its existing layout route. Presence of a Gen2 method must not + -- redirect other games simply because they share the native screen class. + Version.set('red');layers={};s:drawClassic() + T.check(layers[1]~='scene'and layers[1]~='body','Gen1 classic route remains native original route') + layers={};Wide.draw(s) + T.check(layers[1]~='scene'and layers[1]~='body','Gen1 wide route remains native original route') +end +Runtime.wantsHook,Runtime.call=wants,call;Version.set(before) +T.finish('native Gen2 classic/wide scene owner, panel replacement and layer lifecycle') diff --git a/tests/engine/gen2_move_presentation.lua b/tests/engine/gen2_move_presentation.lua new file mode 100644 index 000000000..c05c8850e --- /dev/null +++ b/tests/engine/gen2_move_presentation.lua @@ -0,0 +1,96 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Sound=require('src.core.Sound') +local previous=Version.get() +local function make() + local data=T.fixtures.fresh() + data.moves.FIX_MOVE={id='FIX_MOVE',name='MOVE',power=20,type='NORMAL',pp=20} + data.battle_anims={gen2=true,moveAnims={FIX_MOVE=1},scripts={ + [1]={entry=1,code={{op='delay',frames=2},{op='ret'}}}, + [0x10f]={entry=1,code={{op='delay',frames=3},{op='ret'}}}, + [0x110]={entry=1,code={{op='delay',frames=4},{op='ret'}}}, + [0x112]={entry=1,code={{op='delay',frames=5},{op='ret'}}}, + [0x113]={entry=1,code={{op='delay',frames=6},{op='ret'}}}, + }} + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local game={data=data,save=save,stack={top=function()end}} + local s=Battle.newWild(game,'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + return s,game +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + for _,side in ipairs({'player','enemy'})do + local s=make();local starts,ticks,entryCalls,tail,hits=0,0,0,0,0 + local start,update,entry=s.animPlayer.start,s.animPlayer.update,s.animForMove + s.animPlayer.start=function(self,...)starts=starts+1;return start(self,...)end + s.animPlayer.update=function(self,...)ticks=ticks+1;return update(self,...)end + s.animForMove=function(self,id,who,param,...) + entryCalls=entryCalls+1;T.eq(id,'FIX_MOVE','actual queued move reaches public owner') + T.eq(who,side,'queued side remains native');T.eq(param,19,'queued param reaches real VM') + return entry(self,id,who,param,...) + end + s.applyHitFx=function()hits=hits+1 end + s.queue={{anim='FIX_MOVE',attackerIsPlayer=side=='player',animParam=19,animDelayed=true,hit={sfx='Damage'}}, + {fn=function()tail=tail+1 end}} + local hp,pp=s.player.mon.hp,s.player.curMoves[1].pp + s:updateQueue() + T.eq(starts,1,'one native start per queue row');T.eq(entryCalls,1,'real queue calls source owner exactly once') + T.eq(ticks,0,'starting does not invent a native logic frame') + T.eq(s.animPlayer.vars.param,19,'actual native script parameter') + T.eq(s.animId,nil,'presentation observation does not replace screen state') + T.eq(s.anim.animId,'FIX_MOVE','screen observes actual running animation') + T.eq(s.anims,s.data.battle_anims,'screen exposes actual native animation dataset') + T.eq(s.animPlayer.enemySide,side=='enemy','native VM still owns mirroring') + local frames=0 + while s.animPlaying and frames<200 do + local before=ticks;s:updateQueue();frames=frames+1 + T.eq(ticks,before+1,'one native VM tick per held queue update') + T.eq(tail,0,'pending command remains behind actual animation') + end + T.check(frames<200,'native move terminates') + T.eq(hits,1,'native hit applies once after animation completes') + T.eq(s.anim,nil,'finished animation releases live observation') + s:updateQueue();T.eq(tail,1,'queue releases next command after animation') + T.eq(s.player.mon.hp,hp,'presentation cannot change party HP') + T.eq(s.player.curMoves[1].pp,pp,'presentation cannot spend PP') + T.eq(starts,1,'draw/read completion never restarts VM') + end + for _,side in ipairs({'player','enemy'})do for _,kind in ipairs({'damage','statdown'})do + local s=make();local sounds={};local play=Sound.play + Sound.play=function(_,name)sounds[#sounds+1]=name end + T.eq(s:animForMove('FIX_MOVE',side,7,20,kind),true,'native move with after-program starts') + local name=s:afterAnimFor(side,kind) + local observed=s.anim;local hops=0 + while s.animPlayer.moveId=='FIX_MOVE'and hops<200 do s:updateQueue();hops=hops+1 end + T.check(hops<200,'after program starts when native first program ends') + T.eq(s.animPlayer.moveId,name,'actual named ROM after-program runs') + T.eq(s.animPlayer.enemySide,side=='enemy','after-program keeps native attacker side') + T.eq(observed.animId,'FIX_MOVE','old observation retains original program identity') + T.eq(s.anim.animId,name,'current observation follows actual new program') + T.eq(s.pendingAfterAnim,nil,'after-program is consumed once') + T.eq(#sounds,kind=='damage'and 1 or 0,'only damaging after-program plays hit sound') + if kind=='damage'then T.eq(sounds[1],'Super_Effective','native sound spelling matches effectiveness')end + while s.animPlaying and hops<400 do s:updateQueue();hops=hops+1 end + T.check(hops<400,'after program terminates on native VM clock') + T.eq(s.animPlaying,false,'after-chain releases native hold') + Sound.play=play + end end + do + local s,game=make();game.save.options.animations=false + T.eq(s:animForMove('FIX_MOVE','player'),false,'native option skips real move scripts') + T.eq(s:animForId('ANIM_ENEMY_DAMAGE','player'),true,'non-move animation keeps its native option exemption') + local running=s.anim + T.eq(s:animForId('ANIM_MISSING','player'),false,'unavailable ROM programs remain unavailable') + T.eq(s.anim,running,'failed lookup cannot retire a real running observation') + T.eq(s.anims.ids.ANIM_THROW_POKE_BALL,nil,'name metadata does not fabricate absent programs') + T.eq(s.anims.ids.ANIM_ENEMY_DAMAGE,0x10f,'old native caches expose existing named ROM row') + end +end +Version.set(previous) +T.finish('native Gen2 queued move owner and actual after-animation programs') diff --git a/tests/gpu/gen2_battle_scene.lua b/tests/gpu/gen2_battle_scene.lua new file mode 100644 index 000000000..866d0ee47 --- /dev/null +++ b/tests/gpu/gen2_battle_scene.lua @@ -0,0 +1,68 @@ +-- Generated art, actual native BG/OAM scene composition and GPU readbacks. +local M={} +function M.run() + local Version=require('src.core.GameVersion') + local Palette=require('src.render.PaletteFX') + local Player=require('src.battle.Gen2AnimPlayer') + local Battle=require('src.battle.BattleState') + local g=love.graphics + local previous={version=Version.get(),mode=Palette.mode,ramp=Palette.customRamp()} + local checks,frames=0,0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local function pixel(image,x,y,wanted,label) + local got={image:getPixel(x,y)} + for n=1,4 do check(math.abs(got[n]-wanted[n])<2/255,label..' channel '..n)end + end + local art=love.image.newImageData(8,8,'rgba8') + for y=0,7 do for x=0,7 do art:setPixel(x,y,2/3,2/3,2/3,1)end end + local path='qa-native-scene-obj.png';art:encode('png',path);art:release() + local canvas=g.newCanvas(304,144,{format='rgba8',dpiscale=1});M.image=canvas + local sentinel=g.newShader([[vec4 effect(vec4 c,Image t,vec2 uv,vec2 s){return Texel(t,uv)*c;}]]) + g.push('all') + local ok,why=xpcall(function() + for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition);Palette.setDisplay('gbc',nil) + local player=Player.new({battle_anims={gen2=true,moveAnims={FIX_MOVE=1}, + scripts={[1]={entry=1,code={{op='obj',args={0,40,40,0}},{op='delay',frames=5},{op='ret'}}}}, + objects={[0]={oamFlags=0,fixY=255,frameset=0,fn=0,palette=4,gfx=0}},functions={[0]='Null'}, + framesets={[0]={frames={{oam=0,duration=20}},loop='hold'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}}}, + gfx={[0]={tiles=1,cols=1,image=path}},palettes={[2]={{255,255,255},{220,40,80},{70,160,30},{0,0,0}}}}}) + check(player:start('FIX_MOVE',true),'native scene animation did not start');player:update() + local screen=setmetatable({frame=0,introSlide=0,fx={shakeX=3,shakeY=2},animPlayer=player,animPlaying=true, + colorMode=function()return false end,worldBackdrop=function()return false end, + drawPicsLayer=function()g.setColor(0,0,1,1);g.rectangle('fill',8,16,8,8)end, + drawHUDs=function()g.setColor(1,0,0,1);g.rectangle('fill',0,0,8,8)end, + drawTextArea=function()g.setColor(0,0,0,1);g.rectangle('fill',0,96,160,48)end},Battle) + for _,case in ipairs({'native','replacement','wide','flash'})do + g.setCanvas(canvas);g.origin();g.setScissor();g.setShader();g.setBlendMode('alpha');g.clear(0,0,0,1) + g.setShader(sentinel);g.setColor(.2,.3,.4,.5) + local ticks,age=player.frames,player.objects[1].age + local function panel()g.setShader();g.setColor(0,0,0,1);g.rectangle('fill',0,0,304,144)end + if case=='native'then screen:drawSceneBody() + elseif case=='replacement'then screen:drawSceneBody(panel) + else + screen.fx.flash=case=='flash'and 2 or nil + screen:drawSceneBody(panel,function()g.push();g.translate(136,0);screen:drawAnimLayer(false);g.pop()end,304,144) + end + frames=frames+1 + check(g.getShader()==sentinel,'native scene lost caller shader') + check(math.abs(select(4,g.getColor())-.5)<.001,'native scene lost caller tint') + check(player.frames==ticks and player.objects[1].age==age,'scene rendering advanced native battle time') + g.setCanvas();g.setShader();local image=g.readbackTexture(canvas) + if case=='native'then pixel(image,12,19,{0,0,1,1},'native BG shake')end + if case~='flash'then + pixel(image,(case=='wide'and 168 or 32)+2,26,{220/255,40/255,80/255,1},'native OAM independent of BG shake') + else pixel(image,300,100,{.85,.85,.85,1},'wide flash covers actual native surface')end + image:release() + end + player:release() + end + end,debug.traceback) + g.setCanvas();g.pop();sentinel:release() + Version.set(previous.version);Palette.setDisplay(previous.mode,previous.ramp) + if not ok then error(why,0)end + return {passed=true,checks=checks,frames=frames, + scope='Generated ROM-free Gold/Silver/Crystal native classic panel and real VM/OAM composition, replacement panel, translated wide OAM, full-width flash, state restoration and draw clock stability. Not full UI/art, scanline-effects, whole-mod or OS-window acceptance.'},canvas +end +return M From e340c6673f1b575b5fd3dae3273b0a530522ea27 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:22:40 +0100 Subject: [PATCH 058/187] Observe resolved Gen2 ball programs without repeated OAM allocation --- src/battle/Gen2AnimPlayer.lua | 10 ++++++++++ src/battle/Gen2AnimView.lua | 23 ++++++++++++++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index bc19c78cc..fede60029 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -274,6 +274,16 @@ function Gen2AnimPlayer:start(moveId, attackerIsPlayer, opts) self.loops = {} self.bgSides = {} self.script = self:scriptFor(moveId) + -- Keep the queue's original ID for ball parameters/side handling, while + -- observers identify the actual GSC program (as Gen1Recomp's runner does). + -- A mod-defined alias is canonical only if it resolves to that same script. + self.presentationId = moveId + local alias = GEN1_BALL_ANIMS[moveId] + local canonical = alias == "throwPokeBall" and "ANIM_THROW_POKE_BALL" + or alias == "sendOutMon" and "ANIM_SEND_OUT_MON" + if self.script and canonical and self:scriptFor(canonical) == self.script then + self.presentationId = canonical + end if not self.script then if GEN1_BALL_ANIMS[moveId] == nil then self:warnOnce("anim:" .. tostring(moveId), diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua index 2c45a074b..f2f8d3930 100644 --- a/src/battle/Gen2AnimView.lua +++ b/src/battle/Gen2AnimView.lua @@ -84,7 +84,7 @@ end -- to this same draw owner. Restarting the VM retires the old observation. function View.runner(player) local selected, frozen - local function state() + local function objects() local structs = {} for _, obj in ipairs(selected or player.objects) do local fn = obj.fn and ('BATTLE_ANIM_FUNC_'..obj.fn:gsub('(%l)(%u)','%1_%2'):upper()) @@ -92,16 +92,29 @@ function View.runner(player) if obj.fn == 'PokeBallBlocked' then fn = 'BATTLE_ANIM_FUNC_POKEBALL_BLOCKED' end structs[#structs+1] = {index=obj.index,func=fn,jt=obj.state} end - return {animId=player.moveId,frames=player.frames or 0,var=player.vars and player.vars.var or 0, + return {structs=structs} + end + local function state() + return {animId=player.presentationId or player.moveId,frames=player.frames or 0,var=player.vars and player.vars.var or 0, stopped=player:isDone(),keepSprites=player.keepSprites, - objects={structs=structs}, oam=player:oam(selected)} + objects=objects(), oam=player:oam(selected)} end local runner = {} function runner:oam() return frozen and frozen.oam or player:oam(selected) end function runner.selectObjects(objects) selected=objects end - function runner.retire() frozen=state(); player=nil end + function runner.retire() if not frozen then frozen=state(); player=nil end end return setmetatable(runner, { - __index=function(_,key) return (frozen or state())[key] end, + -- Scalar observations are hot in render/camera callbacks. Do not project + -- OAM and allocate all object records just to read a name or frame count. + __index=function(_,key) + if frozen then return frozen[key] end + if key=='animId'then return player.presentationId or player.moveId end + if key=='frames'then return player.frames or 0 end + if key=='var'then return player.vars and player.vars.var or 0 end + if key=='stopped'then return player:isDone()end + if key=='keepSprites'then return player.keepSprites end + if key=='objects'then return objects()end + end, __newindex=function() error('native Gen2 animation observation is read-only',2) end, }) end From b1b8f4b5ded98a65fd762c56d2d525ddbd7ecabe Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:22:54 +0100 Subject: [PATCH 059/187] Cover native Gen2 ball identity and lazy animation observations --- tests/engine/gen2_animation_identity.lua | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/engine/gen2_animation_identity.lua diff --git a/tests/engine/gen2_animation_identity.lua b/tests/engine/gen2_animation_identity.lua new file mode 100644 index 000000000..c8aa0519d --- /dev/null +++ b/tests/engine/gen2_animation_identity.lua @@ -0,0 +1,49 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Player=require('src.battle.Gen2AnimPlayer') +local View=require('src.battle.Gen2AnimView') +local previous=Version.get() +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local data={battle_anims={gen2=true,moveAnims={FIX_MOVE=1},misc={throwPokeBall=0x100,sendOutMon=0x101}, + scripts={[1]={entry=1,code={{op='delay',frames=1},{op='ret'}}}, + [0x100]={entry=1,code={{op='delay',frames=2},{op='ret'}}}, + [0x101]={entry=1,code={{op='delay',frames=3},{op='ret'}}}}}} + for _,id in ipairs({'TOSS_ANIM','GREATTOSS_ANIM','ULTRATOSS_ANIM','BLOCKBALL_ANIM','POOF_ANIM','SHINY_ANIM','FIX_MOVE'})do + local p=Player.new(data);p:start(id,true,{ballIndex=4}) + local projections=0;local project=p.oam + p.oam=function(self,...)projections=projections+1;return project(self,...)end + local r=View.runner(p);p.presentation=r + local expected=id=='FIX_MOVE'and id or(id=='POOF_ANIM'or id=='SHINY_ANIM')and'ANIM_SEND_OUT_MON'or'ANIM_THROW_POKE_BALL' + T.eq(r.animId,expected,'observation identifies the actual native ROM program') + T.eq(p.moveId,id,'native queue/ball parameter identity is preserved') + T.eq(p.enemySide,id=='POOF_ANIM'or id=='SHINY_ANIM','native send-out side correction is preserved') + if id=='SHINY_ANIM'then T.eq(p.vars.param,1,'native shiny parameter is preserved') + elseif id=='BLOCKBALL_ANIM'then T.eq(p.vars.param,0,'native trainer block parameter is preserved') + elseif id~='POOF_ANIM'and id~='FIX_MOVE'then T.eq(p.vars.param,4,'native ball item parameter is preserved')end + for _=1,100 do + local name,frames,var,stopped,kept=r.animId,r.frames,r.var,r.stopped,r.keepSprites + end + T.eq(r.frames,p.frames,'repeated scalar reads observe native time') + T.eq(projections,0,'scalar queries never project OAM') + T.eq(#r.objects.structs,0,'object inspection remains separate from OAM projection') + T.eq(projections,0,'object metadata queries never project OAM') + r:oam();T.eq(projections,1,'explicit sprite drawing projects actual OAM once') + local before=p.frames;p:update();T.eq(r.frames,p.frames,'live frame observation advances with VM') + T.eq(p.frames,before+1,'observation does not advance the clock') + p:start('FIX_MOVE',false) + T.eq(r.animId,expected,'retired observation preserves original resolved program') + T.eq(projections,2,'retiring captures OAM only once') + r.retire();T.eq(projections,2,'repeated retirement does not rebuild sprites') + end + local p=Player.new(data) + data.battle_anims.moveAnims.TOSS_ANIM=1 + p:start('TOSS_ANIM',true) + T.eq(View.runner(p).animId,'TOSS_ANIM','different mod script cannot acquire native ball identity') + data.battle_anims.scripts[0x100]=nil + p:start('FIX_MOVE',true) + T.eq(View.runner(p).animId,'FIX_MOVE','absent special programs never acquire invented identities') +end +Version.set(previous) +T.finish('native Gen2 animation identity and lazy observation') From a70c0a48cf6eb3861346cdbe519b43e43714e921 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:30:34 +0100 Subject: [PATCH 060/187] Connect a distinct Gen2 model to native reads and battle completion --- docs/native-gen2-model-boundary.md | 38 +++++ src/battle/BattleState.lua | 69 ++++---- src/battle/Damage.lua | 23 +-- src/battle/Gen2BattleRead.lua | 47 ++++++ src/battle/Gen2BattleView.lua | 61 +++++++ src/battle/MoveEffects.lua | 18 +- src/battle/Status.lua | 3 +- src/battle/StatusRegistry.lua | 3 +- src/battle/TurnOrder.lua | 3 +- src/battle/Weather.lua | 3 +- src/battle/gen2/Battle.lua | 67 ++++++++ src/core/ModCompat.lua | 15 +- tests/engine/gen2_battle_model_contract.lua | 178 ++++++++++++++++++++ 13 files changed, 464 insertions(+), 64 deletions(-) create mode 100644 docs/native-gen2-model-boundary.md create mode 100644 src/battle/Gen2BattleRead.lua create mode 100644 src/battle/Gen2BattleView.lua create mode 100644 src/battle/gen2/Battle.lua create mode 100644 tests/engine/gen2_battle_model_contract.lua diff --git a/docs/native-gen2-model-boundary.md b/docs/native-gen2-model-boundary.md new file mode 100644 index 000000000..4abcd7bd4 --- /dev/null +++ b/docs/native-gen2-model-boundary.md @@ -0,0 +1,38 @@ +# Native Gen2 model boundary + +`src.battle.gen2.Battle` is a distinct model class attached to the shared +native battle controller by `Gen2BattleView`. It is not a name alias for +`BattleState`, a second controller, or a detached copy of the saved party. +Methods resolve through the class so an SDK-owned decoration also reaches +already-created models and is restored by loader retirement. + +The boundary supplies live `moveDef`, `speciesDef`, `sideOf`, raw `battleStat` +and `endBattle` operations. Damage, speed, status immunity, weather and shared +move effects consult the native bound reads. Stages, badge/status/item +modifiers, RNG and PP stay with their existing native owners. With no +decoration the read returns the original native table, without a copy. +Temporary stat/type answers do not replace a species registry row, party +statistics, wrapper identity or checkpoint data. Explicit native +Conversion/Transform types keep their priority over a species decoration. + +The native finish path invokes the model's end operation, including the +skipped battle path. It raises one finish notification and permits transient +cleanup before subscribers observe it. `completeBattle` runs the existing +screen finish/return path, with repeated completion prevented from popping +another screen. Existing native consumers retain the screen in the event's +`battle`; the distinct model is available in `model`. + +This is an implemented boundary, not complete source-model parity. The split +Gen2 model contract was inspected at Gen1Recomp `535a5ec0` and its current +`e46d1eb2` counterpart. That model additionally has constructors and a pure +turn/event API, which are not guessed from screen methods here. Its inspected +class has no `battleStat` method; the raw-stat operation above is an explicit +native extension, not a claim that all source versions expose it. Source +event payload normalization and the remaining model operations still require +separate implementation and acceptance. Existing project and third-party +credits remain applicable. + +ROM-free coverage uses an SDK-loaded generic decorator and actual native +damage, speed, status, switch, Transform and completion calls. It also checks +restoration and direct table identity in other generations. It does not prove +an unchanged client, GPU presentation, a full story run or a release package. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8f8e1409c..9b4ef71b4 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -38,6 +38,8 @@ 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 Gen2BattleView = require('src.battle.Gen2BattleView') +local Gen2Read = require('src.battle.Gen2BattleRead') -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -981,7 +983,7 @@ end -- construction -- --------------------------------------------------------------------- -local function makeBattler(data, mon, isPlayer, save) +local function makeBattler(data, mon, isPlayer, save, battle) local def = require('src.pokemon.Gen4Forms').definition(data,mon) local badgeBoosts = data.constants and data.constants.badgeBoosts local badges = nil @@ -993,7 +995,7 @@ local function makeBattler(data, mon, isPlayer, save) if save.inventory[row.badge] then badges[row.badge] = true end end end - return { + local battler = { mon = mon, def = def, name = mon.nickname or def.name, @@ -1050,6 +1052,7 @@ local function makeBattler(data, mon, isPlayer, save) monAnim = (not isPlayer) and require("src.pokemon.MonAnim").new(data, mon.species) or nil, } + return Gen2Read.bind(battler, battle) end -- LinkBattle builds clamped copies with save=nil (no badge boosts); wild @@ -1164,12 +1167,9 @@ local function newBattle(game) local self = setmetatable({}, BattleState) self.game = game self.data = game.data - -- On this engine the battle screen IS the battle logic object: there is no - -- separate model that a screen points at. Publishing the self-reference makes - -- that explicit so anything holding "a thing with a .battle" (the party menu - -- during a switch, mods walking the state stack) can treat the battle screen - -- and the party menu uniformly instead of special-casing this one screen. - self.battle = self + -- Gen2 exposes its actual party through a distinct live model; native + -- decisions, wrappers and presentation remain on this controller. + if GameVersion.isGen2() then Gen2BattleView.bind(self) else self.battle = self end -- BLACK AND WHITE: their move effects named for this engine, their species' -- abilities and their ruleset, before the ruleset below is chosen -- (src/battle/Gen5MoveEffects.lua; sets self.bw on a BW cache only) @@ -1371,7 +1371,7 @@ function BattleState.newWild(game, species, level, opts) Logger.warn("wild battle with no healthy party; skipping") self.dead = true else - self.player = makeBattler(game.data, playerMon, true, game.save) + self.player = makeBattler(game.data, playerMon, true, game.save, self) end local form=opts and opts.form if form==nil and (game.data.constants or {}).gen==4 then @@ -1428,7 +1428,7 @@ function BattleState.newWild(game, species, level, opts) if opts and opts.roamerSeed then Pokemon.applySeed(game.data, wild, opts.roamerSeed) end - self.enemy = makeBattler(game.data, wild, false) + self.enemy = makeBattler(game.data, wild, false, nil, self) require("src.battle.Gen2Wild").initialize(self, opts) if self.roamer then local kept = math.floor(tonumber(opts.roamerHP) or 0) @@ -1552,9 +1552,9 @@ function BattleState.newPokedudeDemo(game, lesson) local self = newBattle(game) self.kind = "wild" self.noRun = true - self.player = makeBattler(game.data, party[1], true, game.save) + self.player = makeBattler(game.data, party[1], true, game.save, self) self.enemyParty = { pokedudeMon(game, def.enemy) } - self.enemy = makeBattler(game.data, self.enemyParty[1], false) + self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self) self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.enemy.name) self.demoName = "POKé DUDE" @@ -1742,9 +1742,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex) Logger.warn("trainer battle with no healthy party; skipping") self.dead = true else - self.player = makeBattler(game.data, playerMon, true, game.save) + self.player = makeBattler(game.data, playerMon, true, game.save, self) end - self.enemy = makeBattler(game.data, self.enemyParty[1], false) + self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self) self.aiUses = self:aiUsesFor() -- wAICount, reset per enemy mon markSeen(game, self.enemyParty[1].species, self.enemyParty[1]) @@ -1836,7 +1836,7 @@ function BattleState:sendOutSecondPair() if self:battlerAt(BattleState.POS.PLAYER_RIGHT) == nil and self.ally then local first = Party.firstHealthy(self.ally.party or {}) if first then - local b = makeBattler(game.data, first, true, nil) + local b = makeBattler(game.data, first, true, nil, self) b.ally = true self:placeBattler(BattleState.POS.PLAYER_RIGHT, b) end @@ -1847,7 +1847,7 @@ function BattleState:sendOutSecondPair() local second = Party.firstHealthy(game.save.party, lead) if second then self:placeBattler(BattleState.POS.PLAYER_RIGHT, - makeBattler(game.data, second, true, game.save)) + makeBattler(game.data, second, true, game.save, self)) elseif GameVersion.isGen4() then -- ...EXCEPT ON PLATINUM, where an escort makes it reachable: the -- partner flag forces hasTwoAliveMons, so a same-id double spots a @@ -1874,7 +1874,7 @@ function BattleState:sendOutSecondPair() if party and party[2] then self.enemyIndexRight = 2 self:placeBattler(BattleState.POS.OPPONENT_RIGHT, - makeBattler(game.data, party[2], false)) + makeBattler(game.data, party[2], false, nil, self)) markSeen(game, party[2].species, party[2]) else -- a "double" trainer with one Pokemon fights alone on their side, @@ -1932,7 +1932,7 @@ function BattleState:addOpponentTrainer(oppClass) -- the right-hand opponent is the SECOND trainer's lead, replacing -- whatever the first trainer's second Pokemon had been put there self:placeBattler(BattleState.POS.OPPONENT_RIGHT, - makeBattler(self.game.data, self.enemyPartyB[1], false)) + makeBattler(self.game.data, self.enemyPartyB[1], false, nil, self)) markSeen(self.game, self.enemyPartyB[1].species, self.enemyPartyB[1]) end return true @@ -2167,7 +2167,7 @@ function BattleState:makeOldManDemo(name) if not self.player then self.dead = false self.player = makeBattler(self.game.data, - Pokemon.new(self.game.data, self.enemy.mon.species, 5), true) + Pokemon.new(self.game.data, self.enemy.mon.species, 5), true, nil, self) end end @@ -3616,7 +3616,8 @@ function BattleState:enter() self.result = "lose" self.game.stack:pop() require("src.core.Music").restoreMap(self.data) - Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true }) + if GameVersion.isGen2() then self.battle:endBattle('lose', {skipped=true}) + else Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true }) end local onFinish = self.onFinish local function blackedOut() if onFinish then onFinish("lose") end @@ -5904,7 +5905,7 @@ function BattleState:switchPlayerInto(pos, newMon) end local previous = self:battlerAt(pos) self:restoreMimicked(previous) -- the battle copy leaves with it - local incoming = makeBattler(self.data, newMon, true, self.game.save) + local incoming = makeBattler(self.data, newMon, true, self.game.save, self) self:placeBattler(pos, incoming) -- BATON PASS: the stat stages and the volatiles go with the switch, which -- is the whole move. Nothing else survives a switch, so a pass that was @@ -6019,7 +6020,7 @@ function BattleState:abilitySwitchIn(battler) local side = self.sideOf and self:sideOf(battler) if side and side.spikes then local grounded = Abilities.of(battler) ~= "LEVITATE" - for _, t in ipairs(battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if t == "FLYING" then grounded = false end end -- PLATINUM / HGSS: subscript_hazards_check decides -- Magic Guard spares @@ -6232,7 +6233,7 @@ function BattleState:abilityForecast(battler) if not (battler and battler.mon and battler.mon.hp > 0) then return false end local want = Abilities.forecastType(battler, Weather.current(self)) if not want then return false end - local types = battler.curTypes + local types = Gen2Read.types(battler) if type(types) == "table" and #types == 1 and types[1] == want then return false end @@ -7785,7 +7786,7 @@ function BattleState:executeAction(user, target, action) local previous = self.enemy local oldName = self.enemy.name self.enemyIndex = action.index - self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) + self.enemy = makeBattler(self.data, self.enemyParty[action.index], false, nil, self) -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap clearTrapping(self.player) self:syncSides() @@ -8120,7 +8121,7 @@ function BattleState:applyGen3TransformVisual(user, target, whiteCoeff) end local function battlerHasType(battler, wanted) - for _, t in ipairs(battler and battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if t == wanted then return true end end return false @@ -8786,7 +8787,7 @@ function BattleState:fillEmptySlots() if pick then local allySlot = isPlayer and self.ally and pos == BattleState.POS.PLAYER_RIGHT self:placeBattler(pos, makeBattler(self.data, pick, isPlayer, - (isPlayer and not allySlot) and self.game.save or nil)) + (isPlayer and not allySlot) and self.game.save or nil, self)) self:syncSides() if not isPlayer then markSeen(self.game, pick.species, pick) end local who = self:battlerAt(pos) @@ -8828,7 +8829,7 @@ function BattleState:promptDoubleReplacement(pos, rest) end end done = true - self:placeBattler(pos, makeBattler(self.data, mon, true, game.save)) + self:placeBattler(pos, makeBattler(self.data, mon, true, game.save, self)) self:syncSides() local who = self:battlerAt(pos) self.nextInsert = 0 @@ -9125,7 +9126,7 @@ function BattleState:enemyMonFainted() end self:act(function() local previous = self.enemy - self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false) + self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false, nil, self) -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap clearTrapping(self.player) self:syncSides() @@ -9163,7 +9164,7 @@ function BattleState:enemyMonFainted() local mon = shiftSwitchMon if not mon then return end local previous = self.player - self.player = makeBattler(self.data, mon, true, self.game.save) + self.player = makeBattler(self.data, mon, true, self.game.save, self) clearTrapping(self.enemy) self:syncSides() Runtime.emit("battle.battler_switched", { @@ -9536,7 +9537,7 @@ function BattleState:openReplacementMenu() end self:restoreMimicked(self.player) local previous = self.player - self.player = makeBattler(self.data, mon, true, game.save) + self.player = makeBattler(self.data, mon, true, game.save, self) clearTrapping(self.enemy) -- SendOutMon clears foe trap self:syncSides() Runtime.emit("battle.battler_switched", { @@ -9764,7 +9765,7 @@ function BattleState:safariEnemyTurn() self:sayNext(self:romText("_SafariZoneAngryText", "Wild %s\nis angry!", self.enemy.name)) end self:act(function() - local speed = self.enemy.curStats.speed % 256 + local speed = Gen2Read.stats(self.enemy).speed % 256 local fled = speed > 127 local b = (speed * 2) % 256 if not fled then @@ -10800,6 +10801,7 @@ function BattleState:playVictoryMusic() end function BattleState:finish() + if self._gen2Complete then return end local pd = self.pokedudeDemo if pd and not pd.finalVoiceQueued then pd.finalVoiceQueued = true @@ -10860,8 +10862,10 @@ function BattleState:finish() -- like the overworld reload's PlayDefaultMusicFadeOutCurrent -- (home/overworld.asm:2343-2348) require("src.core.Music").restoreMap(self.data) + if GameVersion.isGen2() then self._gen2Complete = true end self.game.stack:pop() - Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) + if GameVersion.isGen2() then self.battle:endBattle(self.result or 'run') + else Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) end -- Coming back from the battle screen is a fade, not a cut: EnterMap sees -- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle -- (home/overworld.asm:22, :749-753) = GBFadeInFromWhite. This is the one @@ -12963,4 +12967,5 @@ function BattleState:drawClassic() end end +Gen2BattleView.install(BattleState) return BattleState diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index c287f54a9..60cd86214 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -6,6 +6,7 @@ -- Battlers built by makeBattler also carry the merged badgeBoosts rows and -- statuses records; hand-built battlers fall back to the vanilla tables. +local Gen2Read = require('src.battle.Gen2BattleRead') local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local Stats = require("src.pokemon.Stats") @@ -115,7 +116,7 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit, data) local function shl(x) return math.min(255, x * 2) end local speed if ruleset.critUsesBaseSpeed == false then - speed = Stats.applyStage(attacker.curStats.speed, + speed = Stats.applyStage(Gen2Read.stats(attacker).speed, attacker.stages and attacker.stages.speed or 0) else speed = attacker.def.baseStats.speed @@ -231,6 +232,8 @@ end -- self-hit reads the opponent's screens). -- Returns damage, {crit=bool, typeMult=x10}. function Damage.compute(ruleset, attacker, defender, move, opts) + local attackStats, defendStats = Gen2Read.stats(attacker), Gen2Read.stats(defender) + local attackTypes, defendTypes = Gen2Read.types(attacker), Gen2Read.types(defender) opts = opts or {} local rng = opts.rng or love.math.random -- ...and the same field again, because a STATUS move must not roll damage. @@ -297,18 +300,18 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- Gen 2 kept the same type-based physical/special split but gave the -- attacker Sp.Atk and the defender Sp.Def. A Gen 1 stat block has -- neither key, so it stays on the single `special`. - local split = special and attacker.curStats.spatk and defender.curStats.spdef + local split = special and attackStats.spatk and defendStats.spdef local atkStat = special and (split and "spatk" or "special") or "attack" local defStat = special and (split and "spdef" or "special") or "defense" local atk, dfn if crit and ruleset.critIgnoresStages then - atk = attacker.curStats[atkStat] - dfn = defender.curStats[defStat] + atk = attackStats[atkStat] + dfn = defendStats[defStat] else - atk = Stats.applyStage(attacker.curStats[atkStat], + atk = Stats.applyStage(attackStats[atkStat], stageOf(attacker, atkStat)) - dfn = Stats.applyStage(defender.curStats[defStat], + dfn = Stats.applyStage(defendStats[defStat], stageOf(defender, defStat)) -- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts: -- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder), @@ -464,7 +467,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if not opts.typeless then -- STAB local stab = false - for _, t in ipairs(attacker.curTypes) do + for _, t in ipairs(attackTypes) do if t == move.type then stab = true break end end if stab then @@ -474,7 +477,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- type effectiveness: each TypeEffects row is applied to the -- running damage separately with its own floor (0.5*0.5 lands on -- floor(floor(d/2)/2), not d*0.25) - mult = TypeChart.effectiveness(move.type, defender.curTypes) + mult = TypeChart.effectiveness(move.type, defendTypes) -- FORESIGHT / ODOR SLEUTH: a GHOST that has been identified stops being -- immune to NORMAL and FIGHTING. It does not become weak to them -- -- the immunity simply becomes neutral -- so this reads the chart's @@ -489,7 +492,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if mult == 0 and bwLift then foresight = true mult = 10 - for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do + for _, m in ipairs(TypeChart.rows(move.type, defendTypes)) do if m ~= 0 then mult = math.floor(mult * m / 10) end end end @@ -504,7 +507,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) return 0, { crit = false, typeMult = 0, ability = Abilities.of(defender) } end - for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do + for _, m in ipairs(TypeChart.rows(move.type, defendTypes)) do -- the identified GHOST's zero row is the one skipped; every other -- row a dual-typed Pokemon carries still applies if not (foresight and m == 0) then d = math.floor(d * m / 10) end diff --git a/src/battle/Gen2BattleRead.lua b/src/battle/Gen2BattleRead.lua new file mode 100644 index 000000000..f4e044834 --- /dev/null +++ b/src/battle/Gen2BattleRead.lua @@ -0,0 +1,47 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Pure reads at native calculation sites. Wrappers, PP, volatile state and +-- checkpoint tables retain their identity and original serializable fields. +local Model = require('src.battle.gen2.Battle') +local Read = {} +local owners = setmetatable({}, { __mode = 'kv' }) +local nativeStat, nativeDef = Model.battleStat, Model.speciesDef + +function Read.bind(battler, screen) + if battler and screen and screen.battle and screen.battle ~= screen then + owners[battler] = screen + end + return battler +end + +local function modelOf(battler) + local screen = owners[battler] + local model = screen and screen.battle + -- Non-Gen2 screens use themselves as their model and keep direct reads. + return model ~= screen and model or nil +end + +function Read.stats(battler) + local stats = battler and battler.curStats + if Model.battleStat == nativeStat then return stats end + local model = battler and modelOf(battler) + if not model then return stats end + local values = {} + for key in pairs(stats or {}) do + values[key] = model:battleStat(battler.mon, key) + end + return values +end + +function Read.types(battler) + local types = battler and battler.curTypes + if Model.speciesDef == nativeDef then return types end + local model = battler and modelOf(battler) + if not model then return types end + -- Conversion/Transform own an explicit native type list. A species reader + -- must not erase a move's live volatile override, or mutate its source row. + if not battler.def or types ~= battler.def.types then return types end + local def = model:speciesDef(battler.mon) + return def and def.types or types +end + +return Read diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua new file mode 100644 index 000000000..d5fd55437 --- /dev/null +++ b/src/battle/Gen2BattleView.lua @@ -0,0 +1,61 @@ +-- Copyright (c) 2026 Roxas2712. See the offered contribution licence. +-- Separate live Gen2 model over the existing shared native controller. +local View = {} +local Model = require('src.battle.gen2.Battle') +local function partyOf(screen) + return screen.playerParty or screen.game.save.party or {} +end + +local function indexOf(party, mon) + for index, candidate in ipairs(party or {}) do + if candidate == mon then return index end + end +end + +function View.bind(screen) + -- Bind once; callers read the current native catalog without copying a + -- move, caching a definition, or switching the controller's moveset. + local readers = { + player = function() return screen.player and screen.player.mon end, + enemy = function() return screen.enemy and screen.enemy.mon end, + party = function() return partyOf(screen) end, + enemyParty = function() return screen.enemyParty end, + save = function() return screen.game.save end, + data = function() return screen.data end, + wild = function() return screen.kind == "wild" end, + playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, + enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, + over = function() return screen.result ~= nil or screen.dead == true end, + outcome = function() return screen.result end, + battleType = function() return screen.battleType end, + trainer = function() return screen.trainer end, + tutorial = function() return screen.demo or screen.dudeDemo or screen.pokedudeDemo end, + contest = function() return screen.contest end, + safari = function() return screen.safari and true or false end, + link = function() return screen.kind == "link" end, + kind = function() return screen.kind end, + inBattleTowerBattle = function() return screen.battleTower == true end, + } + screen.battle = Model.bind(setmetatable({}, { + __index = function(_, key) + local read = readers[key] + if read then return read() end + return Model[key] + end, + __newindex = function(model, key, value) + if readers[key] or Model[key] ~= nil then error("native Gen2 battle view is read-only", 2) end + -- Native combat fields stay owned by the engine. Presentation consumers + -- may attach and release their own transient owners, as on the native + -- Gen2 model; these extensions never write to a battler or save. + rawset(model, key, value) + end, + }), screen) +end + +function View.install(Screen) + function Screen:completeBattle() + assert(require('src.core.GameVersion').isGen2(), 'Gen2 completion requires a Gen2 battle') + return self:finish() + end +end +return View diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 5772141ae..d5e4059ae 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -15,6 +15,7 @@ local Logger = require("src.core.Logger") local StatusRegistry = require("src.battle.StatusRegistry") local TurnOrder = require("src.battle.TurnOrder") local TypeChart = require("src.battle.TypeChart") +local Gen2Read = require('src.battle.Gen2BattleRead') local Strings = require("src.core.Strings") local HeldItems = require("src.battle.HeldItems") local Weather = require("src.battle.Weather") @@ -274,7 +275,7 @@ MoveEffects.primary = { if target.leechSeeded then return { Strings("But, it failed!") } end - for _, t in ipairs(target.curTypes) do + for _, t in ipairs(Gen2Read.types(target)) do if t == "GRASS" then return { Strings("But, it failed!") } end end target.leechSeeded = true @@ -370,7 +371,8 @@ MoveEffects.primary = { if target.invulnerable then return { Strings("But, it failed!") } end - user.curTypes = { target.curTypes[1], target.curTypes[2] } + local types = Gen2Read.types(target) + user.curTypes = { types[1], types[2] } -- _ConvertedTypeText return { Strings("Converted type to\n%s's!", displayName(target)) } end, @@ -394,13 +396,15 @@ MoveEffects.primary = { local okT, T = pcall(require, "src.battle.Gen5Transform") if okT then T.apply(battle, user, target) end end + local stats = Gen2Read.stats(target) user.curStats = { hp = user.mon.stats.hp, -- HP is kept - attack = target.curStats.attack, defense = target.curStats.defense, - speed = target.curStats.speed, special = target.curStats.special, - spatk = target.curStats.spatk, spdef = target.curStats.spdef, + attack = stats.attack, defense = stats.defense, + speed = stats.speed, special = stats.special, + spatk = stats.spatk, spdef = stats.spdef, } - user.curTypes = { target.curTypes[1], target.curTypes[2] } + local types = Gen2Read.types(target) + user.curTypes = { types[1], types[2] } -- transform.asm:130-132 copies the target's stat MODS into the user -- (wEnemyMonStatMods -> wPlayerMonStatMods), it does NOT clear them; -- deep copy so later stage changes on either mon stay independent @@ -566,7 +570,7 @@ end -- effectiveness step. So in Gen 1 NIGHT_SHADE hits Normal-types and -- SUPER_FANG hits Ghosts, and only OHKO_EFFECT calls this (#616). local function immuneMsg(ctx) - if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then + if TypeChart.effectiveness(ctx.move.type, Gen2Read.types(ctx.target)) == 0 then return Strings("It doesn't affect\n%s!", displayName(ctx.target)) end return nil diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 955361db0..d81ec5c1b 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -5,6 +5,7 @@ -- and residual sweep. Callers without a battle (pure-module tests) fall -- back to the vanilla records, which is bit-identical behavior. +local Gen2Read = require('src.battle.Gen2BattleRead') local Strings = require("src.core.Strings") local HeldItems = require("src.battle.HeldItems") @@ -24,7 +25,7 @@ end local VOLATILE_PRIORITY = 20 local function hasType(battler, wanted) - for _, t in ipairs(battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if t == wanted then return true end end return false diff --git a/src/battle/StatusRegistry.lua b/src/battle/StatusRegistry.lua index d65321c78..b0b44f619 100644 --- a/src/battle/StatusRegistry.lua +++ b/src/battle/StatusRegistry.lua @@ -3,6 +3,7 @@ -- (canInflict) and so does the landing text (onInflict), so a mod status -- inflicts through the same path as the vanilla five. +local Gen2Read = require('src.battle.Gen2BattleRead') local Runtime = require("src.mods.Runtime") local Status = require("src.battle.Status") local Strings = require("src.core.Strings") @@ -32,7 +33,7 @@ function StatusRegistry.inflict(battle, target, status, opts) -- move's type matches either of the target's types (Body Slam can't -- paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice) if opts.secondary and status ~= "PSN" then - for _, t in ipairs(target.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(target) or {}) do if opts.moveType == t then return {} end end end diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index bb481a7ad..1ebd0c231 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -3,6 +3,7 @@ -- record's priority field; the id table below covers pre-existing -- imported caches (Gen 1 has only QUICK_ATTACK first and COUNTER last). +local Gen2Read = require('src.battle.Gen2BattleRead') local Damage = require("src.battle.Damage") local Stats = require("src.pokemon.Stats") local Status = require("src.battle.Status") @@ -17,7 +18,7 @@ local Abilities = require("src.battle.Abilities") -- nowhere else the weather is visible from here. Every existing caller -- passes nothing and gets exactly the old answer. local function effectiveSpeed(battler, battle) - local spd = Stats.applyStage(battler.curStats.speed, + local spd = Stats.applyStage(Gen2Read.stats(battler).speed, battler.stages and battler.stages.speed or 0) -- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from -- the battler's merged badgeBoosts with the vanilla list as fallback diff --git a/src/battle/Weather.lua b/src/battle/Weather.lua index e3a3fc95e..fc9a18bef 100644 --- a/src/battle/Weather.lua +++ b/src/battle/Weather.lua @@ -14,6 +14,7 @@ -- Weather.start and every other entry point is a nil check on -- battle.field.weather. +local Gen2Read = require('src.battle.Gen2BattleRead') local Strings = require("src.core.Strings") local Weather = {} @@ -296,7 +297,7 @@ local function weatherDamage(battle, battler, weather) return end local immune = WEATHER_IMMUNE[weather] or {} - for _, t in ipairs(battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if immune[t] then return end end battle:animNext(weather == Weather.HAIL and "inHail" or "inSandstorm", diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua new file mode 100644 index 000000000..93efa27f9 --- /dev/null +++ b/src/battle/gen2/Battle.lua @@ -0,0 +1,67 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- The live Gen2 model boundary of the shared native controller. This is not +-- an alias to the screen and does not construct or advance a second battle. +local Runtime = require('src.mods.Runtime') +local Model = {} +local screens = setmetatable({}, { __mode = 'kv' }) +local ended = setmetatable({}, { __mode = 'k' }) + +function Model.bind(model, screen) + screens[model] = screen + return model +end + +local function owner(model) + return assert(screens[model], 'native Gen2 model is not attached to a battle') +end + +local function active(screen, mon) + if screen.player and screen.player.mon == mon then return screen.player end + if screen.enemy and screen.enemy.mon == mon then return screen.enemy end +end + +function Model:moveDef(move) + return owner(self):moveDef(move) +end + +function Model:speciesDef(mon) + return mon and self.data.pokemon and self.data.pokemon[mon.species] or nil +end + +-- Raw native battle statistics, before stages, badges, status or held items. +-- Those modifiers remain in the actual damage/order owners and run once. +-- A caller's temporary shadow mon uses its own stats without changing a party +-- member. Active Transform statistics remain on the existing native wrapper. +function Model:battleStat(mon, key) + local screen = screens[self] + local battler = screen and active(screen, mon) + local stats = battler and battler.curStats or mon and mon.stats + return stats and stats[key] +end + +function Model:sideOf(mon) + local screen = owner(self) + local battler = active(screen, mon) + if battler then return battler.isPlayer and 'player' or 'enemy' end +end + +-- Finish notification is owned by the native result path. Presentation stays +-- on the native queue; completeBattle/finish perform its actual screen return. +-- Existing native event consumers retain the screen in `battle`; the distinct +-- live model is also provided explicitly. No second save/party snapshot exists. +local outcomes = { win=true, lose=true, run=true, caught=true, fled=true, draw=true } +function Model:endBattle(outcome, details) + local screen = owner(self) + assert(outcomes[outcome], 'invalid native Gen2 battle outcome') + if ended[self] then return false end + screen.result = outcome + screen:restoreMimicked(screen.player) + screen:restoreMimicked(screen.enemy) + ended[self] = true + local event = { battle=screen, model=self, result=outcome } + if details and details.skipped then event.skipped = true end + Runtime.emit('battle.ended', event) + return true +end + +return Model diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..69eece921 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -37,17 +37,10 @@ -- src.ui.gen2.BattleAnimView Gen2AnimPlayer is a player, not a view -- src.battle.UIVisibility no equivalent -- src.ui.PartyPanel no equivalent --- src.battle.gen2.Battle AMBIGUOUS, which is the same as absent here. --- src.ui.gen2.BattleState is already mapped to --- src.battle.BattleState below; a port that --- spells BOTH names is not spelling one thing --- twice, it is naming the battle CONTROLLER and --- the battle STATE. This engine has one object --- for both, so whichever of the two a mod meant --- it would get -- and the half of the contract --- it did not mean would be missing, somewhere --- far away from the require. It goes in when a --- mod that wants it says what it calls on it. +-- +-- src.battle.gen2.Battle is a real live model module, not an alias. Its native +-- reads/completion are attached by Gen2BattleView and Gen2BattleRead. Unsupported +-- constructors and turn APIs are not guessed from similarly named screens. -- -- Each of those turns one optional feature off in the mod and says so in the -- log, which is the outcome we want. diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua new file mode 100644 index 000000000..fc535a856 --- /dev/null +++ b/tests/engine/gen2_battle_model_contract.lua @@ -0,0 +1,178 @@ +-- Native calculation and completion paths, decorated through the real SDK. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local Version = require('src.core.GameVersion') +local Screen = require('src.battle.BattleState') +local Model = require('src.battle.gen2.Battle') +local Read = require('src.battle.Gen2BattleRead') +local Pokemon = require('src.pokemon.Pokemon') +local Save = require('src.core.SaveData') +local Order = require('src.battle.TurnOrder') +local Status = require('src.battle.StatusRegistry') +local Effects = require('src.battle.MoveEffects') +local savedVersion = Version.get() +local nativeStat, nativeDef, nativeEnd = Model.battleStat, Model.speciesDef, Model.endBattle + +local function fixture(version) + Version.set(version) + local data = T.fixtures.fresh() + data.constants.gen, data.constants.generation = Version.generation(version), Version.generation(version) + data.type_chart.matchups[#data.type_chart.matchups+1] = { + attacker='NORMAL', defender='GHOST', multiplier=0, + } + local run = T.sdk.loadMods({'mods/model_probe'}, { + data=data, fs=T.sdk.memfs({ + ['mods/model_probe/manifest.json']=[[{ + "id":"model_probe","name":"Native model probe","version":"1.0.0", + "api":2,"entry":"main.lua","affects_link":true + }]], + ['mods/model_probe/main.lua']=[[ + local mod=... + local Model=require('src.battle.gen2.Battle') + local stat,def,finish=Model.battleStat,Model.speciesDef,Model.endBattle + mod.exports.scale=1 + Model.battleStat=function(model,mon,key) + local value=stat(model,mon,key) + if mon==model.player and (key=='attack' or key=='speed') then + return value*mod.exports.scale + end + return value + end + Model.speciesDef=function(model,mon) + local value=def(model,mon) + if not (value and mon==model.enemy and mod.exports.types) then return value end + local result={};for key,item in pairs(value)do result[key]=item end + result.types=mod.exports.types + return result + end + Model.endBattle=function(model,...) + if model.player then model.player._probeTransient=nil end + mod.exports.endCalls=(mod.exports.endCalls or 0)+1 + return finish(model,...) + end + mod.events:on('battle.ended',function(event) + mod.exports.ended=(mod.exports.ended or 0)+1 + mod.exports.event=event + mod.exports.cleanAtEvent=not(event.model and event.model.player + and event.model.player._probeTransient) + end) + ]], + }), + }) + T.eq(#run.errors,0,version..' real SDK model decorator loads') + require('src.render.Font').load(data) + local save=Save.newGame() + save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',18)} + local game={data=data,save=save} + local stack={states={},pops=0} + function stack:top()return self.states[#self.states]end + function stack:pop()self.pops=self.pops+1;return table.remove(self.states)end + function stack:push(state)self.states[#self.states+1]=state end + game.stack=stack + local battle=Screen.newWild(game,'FIXMON_C',23) + stack.states={{},battle} + return battle,game,run,assert(run.loader.exports.model_probe) +end + +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + local b,game,run,probe=fixture(version) + local model=b.battle + local supported=type(model.battleStat)=='function' and type(model.speciesDef)=='function' + T.check(supported,version..' actual model resolves the genuine model class') + T.check(model~=b,version..' screen and model stay distinct') + T.eq(model.player,game.save.party[1],'model retains live party identity') + T.eq(model.moveDef,Model.moveDef,'method lookup retains the class decoration seam') + if supported then + T.eq(model:speciesDef(model.player),game.data.pokemon[model.player.species],'actual species catalog row') + T.eq(model:speciesDef(nil),nil,'no invented missing species') + T.eq(model:battleStat(model.player,'attack'),b.player.curStats.attack,'native raw stat authority') + T.eq(model:battleStat({},'attack'),nil,'unknown stat stays absent') + T.eq(model:sideOf(model.player),'player','native player side') + T.eq(model:sideOf(model.enemy),'enemy','native enemy side') + end + T.eq(Read.stats(b.player).attack,b.player.curStats.attack,'default decorated read keeps native values') + local playerStats,enemyStats=b.player.curStats,b.enemy.curStats + local partyStats,types=b.player.mon.stats,b.enemy.def.types + local pp=b.player.curMoves[1].pp + local move=b:moveDef('FIX_TACKLE') + local function damage() + return b:computeDamage(b.player,b.enemy,move,{forceCrit=false,rng=function(lo,hi)return hi or lo end}) + end + local normal=damage() + local speed=Order.effectiveSpeed(b.player,b) + probe.scale=4 + local boosted=damage() + T.check(boosted>normal,version..' actual native damage consumes decorated model stat') + T.check(Order.effectiveSpeed(b.player,b)>speed,'actual native speed consumes same model stat') + T.eq(b.player.curStats,playerStats,'native stat table identity is retained') + T.eq(b.enemy.curStats,enemyStats,'native opposing stat table is retained') + T.eq(b.player.mon.stats,partyStats,'save stats are not replaced') + T.eq(b.player.curMoves[1].pp,pp,'queries cannot spend PP') + T.eq(Read.stats(b.player).attack,partyStats.attack*4,'live value has no frame cache') + probe.scale=1 + T.eq(damage(),normal,'removing presentation-owned stat override restores native damage') + probe.types={'GHOST'} + T.eq(damage(),0,'actual damage honors model species type immunity') + T.eq(b.enemy.def.types,types,'species registry type list is not replaced') + T.eq(b.enemy.curTypes,types,'native volatile type list is not replaced') + probe.types={'FIRE'} + b.enemy.mon.status=nil + Status.inflict(b,b.enemy,'BRN',{}) + T.eq(b.enemy.mon.status,nil,'actual native status immunity honors model type') + probe.types={'NORMAL'} + Status.inflict(b,b.enemy,'BRN',{}) + T.eq(b.enemy.mon.status,'BRN','removing immunity restores native status path') + b.enemy.mon.status=nil + b.enemy.curTypes={'WATER'} + probe.types={'GHOST'} + T.eq(Read.types(b.enemy)[1],'WATER','native Conversion list wins over a species decoration') + T.check(damage()>0,'volatile type override reaches actual damage') + b.enemy.curTypes=types + probe.types=nil + local old=b.player + b:switchPlayerInto(Screen.POS.PLAYER_LEFT,game.save.party[2]) + T.eq(model.player,game.save.party[2],'model follows actual native switch') + probe.scale=2 + T.eq(Read.stats(b.player).attack,b.player.curStats.attack*2,'new native battler is bound') + T.neq(b.player,old,'native wrapper really changed') + probe.scale=1 + -- Native Transform keeps temporary stats on its wrapper, never the save. + local source=b.enemy.curStats.attack + Effects.primary.TRANSFORM_EFFECT(b,b.player,b.enemy) + if supported then T.eq(model:battleStat(model.player,'attack'),source,'Transform uses actual native volatile stat')end + T.eq(model.player.stats,game.save.party[2].stats,'Transform does not replace saved stats') + model.player._probeTransient=true + b.result='run' + local complete=type(b.completeBattle)=='function' + T.check(complete,'native Gen2 completion seam exists') + if complete then + b:completeBattle() + T.eq(probe.ended,1,'actual native finish emits one ended event') + T.eq(probe.event.battle,b,'existing native consumers retain their screen owner') + T.eq(probe.event.model,model,'notification also exposes actual live model') + T.check(probe.cleanAtEvent,'model end decoration cleans before native end notification') + T.eq(game.stack.pops,1,'completion uses native stack return once') + b:completeBattle() + T.eq(game.stack.pops,1,'repeated completion cannot pop another screen') + if type(model.endBattle)=='function'then model:endBattle('run')end + T.eq(probe.ended,1,'repeated model completion cannot re-emit ended event') + end + require('src.mods.Loader').teardown() + run.release() + T.eq(Model.battleStat,nativeStat,'loader retirement restores model stat method') + T.eq(Model.speciesDef,nativeDef,'loader retirement restores model species method') + T.eq(Model.endBattle,nativeEnd,'loader retirement restores model end method') + T.eq(Read.stats(b.player),b.player.curStats,'undecorated read returns original stat table') + T.eq(Read.types(b.enemy),b.enemy.curTypes,'undecorated read returns original type list') +end +for _,version in ipairs({'red','firered','emerald'})do + local b,game,run,probe=fixture(version) + T.eq(b.battle,b,version..' keeps its original shared native identity') + probe.scale=9;probe.types={'GHOST'} + T.eq(Read.stats(b.player),b.player.curStats,'other generation retains exact raw stat table') + T.eq(Read.types(b.enemy),b.enemy.curTypes,'other generation retains exact raw type list') + require('src.mods.Loader').teardown() + run.release() +end +Version.set(savedVersion) +T.finish('native Gen2 model calculation and completion boundary') From a1684d351cb0e50d5c5509f46bae0fd1dfd0f810 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:33:05 +0100 Subject: [PATCH 061/187] Keep scalar model queries separate from older native screen signature --- src/battle/gen2/Battle.lua | 3 +++ tests/engine/gen2_battle_model_contract.lua | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 93efa27f9..1b4fb282f 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -21,6 +21,9 @@ local function active(screen, mon) end function Model:moveDef(move) + -- Older native screens take a move instance; scalar catalog queries belong + -- to this model and must not pass an id through that older signature. + if type(move) ~= 'table' then return self.data.moves[move] end return owner(self):moveDef(move) end diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua index fc535a856..e128523f7 100644 --- a/tests/engine/gen2_battle_model_contract.lua +++ b/tests/engine/gen2_battle_model_contract.lua @@ -94,7 +94,7 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do local playerStats,enemyStats=b.player.curStats,b.enemy.curStats local partyStats,types=b.player.mon.stats,b.enemy.def.types local pp=b.player.curMoves[1].pp - local move=b:moveDef('FIX_TACKLE') + local move=b:moveDef(b.player.curMoves[1]) local function damage() return b:computeDamage(b.player,b.enemy,move,{forceCrit=false,rng=function(lo,hi)return hi or lo end}) end From a0baf99219097428a7fe5e58f71226157af5a4dc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:37:00 +0100 Subject: [PATCH 062/187] Preserve native link and extension completion labels --- src/battle/gen2/Battle.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 1b4fb282f..044aaa6aa 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -52,10 +52,10 @@ end -- on the native queue; completeBattle/finish perform its actual screen return. -- Existing native event consumers retain the screen in `battle`; the distinct -- live model is also provided explicitly. No second save/party snapshot exists. -local outcomes = { win=true, lose=true, run=true, caught=true, fled=true, draw=true } function Model:endBattle(outcome, details) local screen = owner(self) - assert(outcomes[outcome], 'invalid native Gen2 battle outcome') + -- Preserve link/spectator and extension-owned native string outcomes. + assert(type(outcome) == 'string' and outcome ~= '', 'invalid native Gen2 battle outcome') if ended[self] then return false end screen.result = outcome screen:restoreMimicked(screen.player) From ad95984609d8b587eb8e6790432239d01bb6f657 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:37:34 +0100 Subject: [PATCH 063/187] Cover native link and spectator outcome strings in model fixture --- tests/engine/gen2_battle_model_contract.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua index e128523f7..7b6acba6b 100644 --- a/tests/engine/gen2_battle_model_contract.lua +++ b/tests/engine/gen2_battle_model_contract.lua @@ -142,12 +142,14 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do if supported then T.eq(model:battleStat(model.player,'attack'),source,'Transform uses actual native volatile stat')end T.eq(model.player.stats,game.save.party[2].stats,'Transform does not replace saved stats') model.player._probeTransient=true - b.result='run' + b.result=({gold='ended',silver='hostWin',crystal='guestWin',prism='draw',polishedcrystal='run'})[version] + local outcome=b.result local complete=type(b.completeBattle)=='function' T.check(complete,'native Gen2 completion seam exists') if complete then b:completeBattle() T.eq(probe.ended,1,'actual native finish emits one ended event') + T.eq(probe.event.result,outcome,'completion retains the native owner string unchanged') T.eq(probe.event.battle,b,'existing native consumers retain their screen owner') T.eq(probe.event.model,model,'notification also exposes actual live model') T.check(probe.cleanAtEvent,'model end decoration cleans before native end notification') From 870dbab1b5d56cfa9fb3a2a29757d823d9554ec1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:39:49 +0100 Subject: [PATCH 064/187] Preserve absent native result while reporting completion fallback --- src/battle/BattleState.lua | 2 +- src/battle/Gen2BattleView.lua | 2 +- src/battle/gen2/Battle.lua | 2 +- tests/engine/gen2_battle_model_contract.lua | 5 +++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9b4ef71b4..11de665d4 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -10864,7 +10864,7 @@ function BattleState:finish() require("src.core.Music").restoreMap(self.data) if GameVersion.isGen2() then self._gen2Complete = true end self.game.stack:pop() - if GameVersion.isGen2() then self.battle:endBattle(self.result or 'run') + if GameVersion.isGen2() then self.battle:endBattle(self.result or 'run', {nativeNotification=true}) else Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) end -- Coming back from the battle screen is a fade, not a cut: EnterMap sees -- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua index d5fd55437..06605b8e1 100644 --- a/src/battle/Gen2BattleView.lua +++ b/src/battle/Gen2BattleView.lua @@ -25,7 +25,7 @@ function View.bind(screen) wild = function() return screen.kind == "wild" end, playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, - over = function() return screen.result ~= nil or screen.dead == true end, + over = function() return screen.result ~= nil or screen.dead == true or screen._gen2Complete == true end, outcome = function() return screen.result end, battleType = function() return screen.battleType end, trainer = function() return screen.trainer end, diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 044aaa6aa..8f00ffd0f 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -57,7 +57,7 @@ function Model:endBattle(outcome, details) -- Preserve link/spectator and extension-owned native string outcomes. assert(type(outcome) == 'string' and outcome ~= '', 'invalid native Gen2 battle outcome') if ended[self] then return false end - screen.result = outcome + if not (details and details.nativeNotification) then screen.result = outcome end screen:restoreMimicked(screen.player) screen:restoreMimicked(screen.enemy) ended[self] = true diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua index 7b6acba6b..dd622c770 100644 --- a/tests/engine/gen2_battle_model_contract.lua +++ b/tests/engine/gen2_battle_model_contract.lua @@ -142,14 +142,15 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do if supported then T.eq(model:battleStat(model.player,'attack'),source,'Transform uses actual native volatile stat')end T.eq(model.player.stats,game.save.party[2].stats,'Transform does not replace saved stats') model.player._probeTransient=true - b.result=({gold='ended',silver='hostWin',crystal='guestWin',prism='draw',polishedcrystal='run'})[version] + b.result=({gold='ended',silver='hostWin',crystal='guestWin',prism='draw'})[version] local outcome=b.result local complete=type(b.completeBattle)=='function' T.check(complete,'native Gen2 completion seam exists') if complete then b:completeBattle() T.eq(probe.ended,1,'actual native finish emits one ended event') - T.eq(probe.event.result,outcome,'completion retains the native owner string unchanged') + T.eq(probe.event.result,outcome or 'run','notification retains the native event fallback') + T.eq(b.result,outcome,'notification does not persist an event-only fallback') T.eq(probe.event.battle,b,'existing native consumers retain their screen owner') T.eq(probe.event.model,model,'notification also exposes actual live model') T.check(probe.cleanAtEvent,'model end decoration cleans before native end notification') From 395c52643f6be50d5501527b9acdd3265d9e60c1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:43:44 +0100 Subject: [PATCH 065/187] Bind external native battlers on placement and side synchronization --- src/battle/BattleState.lua | 8 +++++++- tests/engine/gen2_battle_model_contract.lua | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 11de665d4..9c683585c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3432,7 +3432,10 @@ end function BattleState:placeBattler(pos, battler) local side, flank = slotOf(pos) self.sides[side].battlers[flank] = battler - if battler then battler.position = pos end + if battler then + battler.position = pos + Gen2Read.bind(battler, self) + end if flank == 1 then if side == 1 then self.player = battler else self.enemy = battler end end @@ -3552,6 +3555,9 @@ function BattleState:syncSides() if pr then pr.position = BattleState.POS.PLAYER_RIGHT end if orr then orr.position = BattleState.POS.OPPONENT_RIGHT end end + for _, battler in activeBattlers(self) do + Gen2Read.bind(battler, self) + end end -- IS THIS SIDE STILL ARRIVING? diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua index dd622c770..b16f8d48c 100644 --- a/tests/engine/gen2_battle_model_contract.lua +++ b/tests/engine/gen2_battle_model_contract.lua @@ -137,6 +137,18 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do T.neq(b.player,old,'native wrapper really changed') probe.scale=1 -- Native Transform keeps temporary stats on its wrapper, never the save. + -- Link/scaffold owners use the public factory without a screen argument, + -- then place/synchronize their actual wrappers on the native controller. + local placed=Screen.makeBattler(game.data,game.save.party[1],true,nil) + b:placeBattler(Screen.POS.PLAYER_LEFT,placed) + probe.scale=3 + T.eq(Read.stats(placed).attack,placed.curStats.attack*3,'native placement binds a public-factory wrapper') + local rebound=Screen.makeBattler(game.data,game.save.party[2],true,nil) + b.player=rebound + b:syncSides() + T.eq(Read.stats(rebound).attack,rebound.curStats.attack*3,'native side synchronization binds external wrappers') + T.eq(model.player,game.save.party[2],'external native replacement retains live model identity') + probe.scale=1 local source=b.enemy.curStats.attack Effects.primary.TRANSFORM_EFFECT(b,b.player,b.enemy) if supported then T.eq(model:battleStat(model.player,'attack'),source,'Transform uses actual native volatile stat')end From 29990c07668253629556f7808b6c0764045a1450 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:34:53 +0100 Subject: [PATCH 066/187] Restore licensed GSC BG backend API and edition-specific cartridge identifiers --- README.md | 6 + compat/gen1-gsc/LICENSE.MD | 759 ++++++++++++++ compat/gen1-gsc/provenance.json | 20 + src/battle/Gen2AnimMath.lua | 30 + src/battle/Gen2BgEffectIds.lua | 73 ++ src/battle/gen2/BgEffects.lua | 1377 ++++++++++++++++++++++++++ src/import/RomImporter.lua | 8 +- tests/engine/gen2_native_bg_pool.lua | 72 ++ 8 files changed, 2343 insertions(+), 2 deletions(-) create mode 100644 compat/gen1-gsc/LICENSE.MD create mode 100644 compat/gen1-gsc/provenance.json create mode 100644 src/battle/Gen2AnimMath.lua create mode 100644 src/battle/Gen2BgEffectIds.lua create mode 100644 src/battle/gen2/BgEffects.lua create mode 100644 tests/engine/gen2_native_bg_pool.lua diff --git a/README.md b/README.md index 4cd31e7c0..86a86f35c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +Based on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC +(https://github.com/bryanthaboi/gen1recomp). + +GSC BG-effect source provenance and its retained licence are recorded in +[compat/gen1-gsc/provenance.json](compat/gen1-gsc/provenance.json). + # gen2recomp

diff --git a/compat/gen1-gsc/LICENSE.MD b/compat/gen1-gsc/LICENSE.MD new file mode 100644 index 000000000..806424f26 --- /dev/null +++ b/compat/gen1-gsc/LICENSE.MD @@ -0,0 +1,759 @@ +# License + +Copyright (C) 2026 BOIS CLUB GAMES, LLC + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation, version 3 of the License, with the Additional Terms below, which +are added under Section 7 of that License. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +## Additional Terms (GPLv3 Section 7) + +### 1. Attribution (Section 7(b)) + +Any copy, fork, modified version, or work based on this program, in source or +object form, and for any purpose, must: + +- keep this license file and every copyright and author notice in the source; +- give clear, visible credit to the original project in its README (or + equivalent project page) and in any in-app credits, "about", or title + screen, reading at minimum: + "Based on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC + (https://github.com/bryanthaboi/gen1recomp)"; +- show that same credit, visibly, inside any launcher, front end, menu shell, + or other program used to start or select the game, including any launcher + written to replace the one excluded in term 2. + +Using this program, in whole or in part, for other projects is permitted, as +long as this credit is always given. + +### 2. The Launcher is not licensed under the GPL + +The project launcher (the "Launcher") is NOT part of the program licensed +above. It is proprietary: Copyright (C) 2026 BOIS CLUB GAMES, LLC, all rights +reserved. The Launcher may not be copied, modified, redistributed, or used in +any fork, modified version, or other project, in whole or in part. A fork or +derivative must provide its own launcher, which must carry the credit +required by term 1. + +The Launcher consists of the following files, and any later version of them: + +- `src/import/LauncherView.lua` +- `src/import/LauncherSettings.lua` +- `src/import/OnlinePanel.lua` +- `src/import/CartLabelArt.lua` +- `src/import/CartShape.lua` +- `src/mods/LauncherMods.lua` +- the launcher user interface code in `src/import/RomImporter.lua` (the + ROM import, verification, and cache extraction logic in that file remains + under the GPL) +- `assets/launcher/` and `assets/labels/` + +Distributing the Launcher together with this program is permitted only in +unmodified official releases from BOIS CLUB GAMES, LLC. + +### PortMaster distribution permission + +As an exception to the Launcher distribution restriction above, BOIS CLUB +GAMES, LLC grants permission to distribute the Launcher with this program +through PortMaster, including the PortMaster-MV repository and its release +packages. This permission covers PortMaster packaging and launch scripts; +it does not grant a general right to reuse or modify the proprietary Launcher +in other projects. Retain this license and the attribution required above. + +This exception records the explicit PortMaster permission granted by project +owner bryanthaboi on 6 October 2026. All other terms remain unchanged. + +### 3. Names and marks (Section 7(e)) + +This license grants no rights to use the names, logos, or trademarks of BOIS +CLUB GAMES, LLC or of the project, except as needed to give the credit +required by term 1. + +### 4. Third-party rights + +This project contains no ROM data and no extracted game content. Pokemon and +all related names and content are the property of Nintendo, Creatures Inc., +and GAME FREAK inc. This license covers only the original code and assets of +this project. + +--- + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/compat/gen1-gsc/provenance.json b/compat/gen1-gsc/provenance.json new file mode 100644 index 000000000..d9ee9f74a --- /dev/null +++ b/compat/gen1-gsc/provenance.json @@ -0,0 +1,20 @@ +{ + "repository": "https://github.com/bryanthaboi/gen1recomp", + "commit": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "files": { + "src/battle/gen2/BgEffects.lua": "e50650aa3315a2545cf0044fbf226076be0086a63b097ef5d682e2beefb5491e", + "src/battle/gen2/AnimObjects.lua": "7713be1ee3b88284a3399baeb9e81d5c0a98c421990336bec3ebb31e832982f6", + "src/ui/gen2/SpriteAnims.lua": "31c213a712edab13d03f925d3f4cd4f487b2cbd724ac50692f576cd04261ce65", + "tools/rom_manifest_gold.json": "ebfd906ef715c634c12ca168dbf711161385720f02c6e0a63d9481a815550a1e", + "tools/rom_manifest_silver.json": "7575919b843c2addf096ed3db7b37dedb9f4b9c8129d32eb0d9ed6c9e5333f52", + "tools/rom_manifest_crystal.json": "be373e361090d79f413ad4d1e57d28c5ac1aa00650d4fa361289a78ef8397b31", + "LICENSE.MD": "b369ce45656b3d87453541816fac6982b13e09ea0734ab9cfe61cf3faf5e95ed" + }, + "scope": "BgEffects implementation, arithmetic subset and BG-effect identifier lists only. No launcher, game assets, ROM bytes or player state copied.", + "modifications": [ + "BgEffects arithmetic dependency uses Gen2AnimMath instead of the full reference object pool.", + "A missing jump-table list resolves through edition-specific Gen2BgEffectIds for older native caches." + ], + "licence": "Gen1Recomp GPLv3 with its Additional Terms, retained in LICENSE.MD. Existing authors retain their rights; Roxas2712 integration changes in these derived files are offered on those applicable terms.", + "qualification": "Standalone native BG API and CPU state-machine traces only. Native animation VM / drawing integration and actual client GPU acceptance remain required before merge." +} diff --git a/src/battle/Gen2AnimMath.lua b/src/battle/Gen2AnimMath.lua new file mode 100644 index 000000000..aa4d7f846 --- /dev/null +++ b/src/battle/Gen2AnimMath.lua @@ -0,0 +1,30 @@ +-- Gen1Recomp-derived code: GPLv3 plus Additional Terms; see +-- compat/gen1-gsc/LICENSE.MD and provenance.json. Existing rights retained. +-- GSC byte arithmetic and sine from Gen1Recomp's AnimObjects/SpriteAnims at +-- 535a5ec0. Existing source rights/licences remain under compat/gen1-gsc/LICENSE.MD. +-- This is arithmetic only; the native VM still owns its objects and clock. +local bit = require('bit') +local M = {} +function M.u8(value) return value % 256 end +local function s8(value) + value = value % 256 + return value < 0x80 and value or value - 256 +end +function M.sra(value) return M.u8(math.floor(s8(value) / 2)) end +function M.swap(value) + value = M.u8(value) + return bit.bor(bit.rshift(value,4), bit.band(bit.lshift(value,4),0xf0)) +end +local sine = {} +for i=0,31 do sine[i] = math.floor(math.sin(i*math.pi/32)*256+0.5) end +function M.sine(angle, amplitude) + angle = angle % 64 + local negative = angle >= 32 + if negative then angle = angle - 32 end + local product = (amplitude % 256) * sine[angle] % 0x10000 + local result = math.floor(product / 256) + if negative then result = -result end + return result % 256 +end +function M.cosine(angle, amplitude) return M.sine(angle+0x10,amplitude) end +return M diff --git a/src/battle/Gen2BgEffectIds.lua b/src/battle/Gen2BgEffectIds.lua new file mode 100644 index 000000000..21f34e0fc --- /dev/null +++ b/src/battle/Gen2BgEffectIds.lua @@ -0,0 +1,73 @@ +-- Gen1Recomp-derived code: GPLv3 plus Additional Terms; see +-- compat/gen1-gsc/LICENSE.MD and provenance.json. Existing rights retained. +-- GSC BG-effect jump-table identifiers from Gen1Recomp's Gold/Silver/Crystal +-- ROM manifests at535a5ec0, constants.battleBgEffectOrder. Numeric command +-- arguments retain the cartridge's order; no program or frame is synthesized. +local base = { + "BATTLE_BG_EFFECT_END", + "BATTLE_BG_EFFECT_FLASH_INVERTED", + "BATTLE_BG_EFFECT_FLASH_WHITE", + "BATTLE_BG_EFFECT_WHITE_HUES", + "BATTLE_BG_EFFECT_BLACK_HUES", + "BATTLE_BG_EFFECT_ALTERNATE_HUES", + "BATTLE_BG_EFFECT_CYCLE_OBPALS_GRAY_AND_YELLOW", + "BATTLE_BG_EFFECT_CYCLE_MID_OBPALS_GRAY_AND_YELLOW", + "BATTLE_BG_EFFECT_CYCLE_BGPALS_INVERTED", + "BATTLE_BG_EFFECT_HIDE_MON", + "BATTLE_BG_EFFECT_SHOW_MON", + "BATTLE_BG_EFFECT_ENTER_MON", + "BATTLE_BG_EFFECT_RETURN_MON", + "BATTLE_BG_EFFECT_SURF", + "BATTLE_BG_EFFECT_WHIRLPOOL", + "BATTLE_BG_EFFECT_TELEPORT", + "BATTLE_BG_EFFECT_NIGHT_SHADE", + "BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", + "BATTLE_BG_EFFECT_BATTLEROBJ_2ROW", + "BATTLE_BG_EFFECT_DOUBLE_TEAM", + "BATTLE_BG_EFFECT_ACID_ARMOR", + "BATTLE_BG_EFFECT_RAPID_FLASH", + "BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT", + "BATTLE_BG_EFFECT_FADE_MON_TO_BLACK", + "BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT_REPEATING", + "BATTLE_BG_EFFECT_FADE_MON_TO_BLACK_REPEATING", + "BATTLE_BG_EFFECT_CYCLE_MON_LIGHT_DARK_REPEATING", + "BATTLE_BG_EFFECT_FLASH_MON_REPEATING", + "BATTLE_BG_EFFECT_FADE_MONS_TO_BLACK_REPEATING", + "BATTLE_BG_EFFECT_FADE_MON_TO_WHITE_WAIT_FADE_BACK", + "BATTLE_BG_EFFECT_FADE_MON_FROM_WHITE", + "BATTLE_BG_EFFECT_SHAKE_SCREEN_X", + "BATTLE_BG_EFFECT_SHAKE_SCREEN_Y", + "BATTLE_BG_EFFECT_WITHDRAW", + "BATTLE_BG_EFFECT_BOUNCE_DOWN", + "BATTLE_BG_EFFECT_DIG", + "BATTLE_BG_EFFECT_TACKLE", + "BATTLE_BG_EFFECT_WOBBLE_MON", + "BATTLE_BG_EFFECT_REMOVE_MON", + "BATTLE_BG_EFFECT_WAVE_DEFORM_MON", + "BATTLE_BG_EFFECT_PSYCHIC", + "BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1", + "BATTLE_BG_EFFECT_BETA_SEND_OUT_MON2", + "BATTLE_BG_EFFECT_FLAIL", + "BATTLE_BG_EFFECT_BETA_PURSUIT", + "BATTLE_BG_EFFECT_ROLLOUT", + "BATTLE_BG_EFFECT_VITAL_THROW", + "BATTLE_BG_EFFECT_START_WATER", + "BATTLE_BG_EFFECT_WATER", + "BATTLE_BG_EFFECT_END_WATER", + "BATTLE_BG_EFFECT_VIBRATE_MON", + "BATTLE_BG_EFFECT_WOBBLE_PLAYER", + "BATTLE_BG_EFFECT_WOBBLE_SCREEN", +} +local M = {} +function M.forVersion(version) + version = version or require('src.core.GameVersion').get() + local order = {} + for i,name in ipairs(base) do + if i==38 and version~='gold' and version~='silver' then + order[#order+1] = 'BATTLE_BG_EFFECT_BODY_SLAM' + end + order[#order+1] = name + end + return order +end +return M diff --git a/src/battle/gen2/BgEffects.lua b/src/battle/gen2/BgEffects.lua new file mode 100644 index 000000000..be080a185 --- /dev/null +++ b/src/battle/gen2/BgEffects.lua @@ -0,0 +1,1377 @@ +-- Gen1Recomp-derived code: GPLv3 plus Additional Terms; see +-- compat/gen1-gsc/LICENSE.MD and provenance.json. Existing rights retained. +-- Gen1Recomp source: src/battle/gen2/BgEffects.lua at535a5ec0. +-- Its existing copyrights/licences remain effective (compat/gen1-gsc/LICENSE.MD). +-- Native dependency integration: Roxas2712, under the same applicable terms. +-- The Gen 2 battle-animation BACKGROUND effects. +-- +-- pokegold engine/battle_anims/bg_effects.asm: five concurrent structs, each +-- of {function, jumptable index, battle turn, param}, and a jumptable of 53 +-- effects that between them do all the shaking, flashing and sinking a Gen 2 +-- battle animation is made of. Objects (src/battle/gen2/AnimObjects.lua) are +-- OBJs; these are the BG layer. +-- +-- The one mechanism worth understanding before reading any of it: almost +-- nothing here moves a sprite. It writes wLYOverridesBackup, a per-scanline +-- value the LCD STAT interrupt copies into rSCX, rSCY or rBGP as the beam +-- passes. BattleBGEffect_SetLCDStatCustoms1 picks the window -- scanlines +-- $00-$36 for the enemy's pic, $2f-$5e for the player's -- so "shake the +-- attacker" is "write the same SCX to every scanline the attacker occupies". +-- +-- The port keeps that literally: `lyBackup` is 144 bytes, `lcdc` names the +-- register they land in, and src/ui/gen2/BattleAnimView.lua draws the BG layer +-- one scanline band at a time. Modelling it as "move the mon pic" instead +-- would work for Tackle and then fall apart on Withdraw and Dig, which push a +-- GROWING number of rows off and leave the rest where they are. +-- +-- Love-free, like AnimObjects. + +local bit = require("bit") +-- Arithmetic shared with the reference object engine; the fork keeps its +-- existing OAM owner instead of starting a second object pool. +local AnimObjects = require("src.battle.Gen2AnimMath") + +local u8, sra = AnimObjects.u8, AnimObjects.sra +local swap = AnimObjects.swap +local sine, cosine = AnimObjects.sine, AnimObjects.cosine + +local BgEffects = {} + +local NUM_EFFECTS = 5 -- NUM_BG_EFFECTS +local SCREEN_ROWS = 0x90 -- wLYOverridesBackup is $91 bytes + +-- `dc a, b, c, d` packs four 2-bit shades into a DMG palette byte, high pair +-- first: `dc 3, 2, 1, 0` is %11100100 = $e4, the identity ramp. +local function dc(a, b, c, d) return a * 64 + b * 16 + c * 4 + d end + +local NORMAL_PAL = dc(3, 2, 1, 0) + +-------------------------------------------------------------------------- + +local Pool = {} +Pool.__index = Pool + +local function newEffect() + return { func = nil, jt = 0, turn = 0, param = 0, side = nil } +end + +-- `env` is shared with the object pool: env.battleTurn is hBattleTurn, and +-- env.flying tells BGEffect_CheckFlyDigStatus whether the battler in question +-- is mid-Fly or mid-Dig (in which case ShowMon and the battler-pic objects +-- decline to draw a mon that is not on the field). +function BgEffects.new(constants, env) + local self = setmetatable({}, Pool) + self.env = env or {} + self.effects = {} + for slot = 1, NUM_EFFECTS do self.effects[slot] = newEffect() end + self.order = (constants or {}).battleBgEffectOrder or require("src.battle.Gen2BgEffectIds").forVersion() + self:reset() + return self +end + +function Pool:reset() + for slot = 1, NUM_EFFECTS do self.effects[slot] = newEffect() end + -- hSCX / hSCY: a whole-screen scroll, which is what the screen shakes use. + self.scx, self.scy = 0, 0 + -- hLCDCPointer plus its window, and the per-scanline values themselves. + self.lcdc, self.lyStart, self.lyEnd = nil, 0, 0 + self.lyBackup = {} + for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end + -- wBGP / wOBP0 / wOBP1, as DMG palette bytes. + self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL + -- Per-battler state the CGB paths write instead of touching wBGP: shade + -- byte, hidden flag, lifted tile rows, and which BG square it is drawn at. + self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL } + self.hidden = { player = false, enemy = false } + self.liftedRows = { player = nil, enemy = nil } + self.picSize = { player = nil, enemy = nil } + self.slide = { player = 0, enemy = 0 } + -- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the + -- overrides. nil until InitSurfWaves lays one down. + self.surfWave = nil + -- The objects a BG effect asks the object pool to spawn, drained by the + -- runner after each frame. + self.spawns = {} +end + +function Pool:activeCount() + local count = 0 + for slot = 1, NUM_EFFECTS do + if self.effects[slot].func then count = count + 1 end + end + return count +end + +-- QueueBGEffect: first free struct wins; a full pool silently drops the +-- request, which is exactly what the carry return means to the caller. +function Pool:queue(effectId, jumptableIndex, turn, param) + local name = effectId + if type(effectId) == "number" then + name = self.order[effectId + 1] or effectId + end + for slot = 1, NUM_EFFECTS do + local st = self.effects[slot] + if not st.func then + st.func = name + st.jt = u8(jumptableIndex or 0) + st.turn = u8(turn or 0) + st.param = u8(param or 0) + st.side = nil + return st + end + end + return nil +end + +-- BattleAnimCmd_IncBGEffect: bump the jumptable index of the first struct +-- running this effect. +function Pool:incEffect(effectId) + local name = effectId + if type(effectId) == "number" then + name = self.order[effectId + 1] or effectId + end + for slot = 1, NUM_EFFECTS do + local st = self.effects[slot] + if st.func == name then + st.jt = u8(st.jt + 1) + return st + end + end + return nil +end + +-------------------------------------------------------------------------- +-- Shared helpers +-------------------------------------------------------------------------- + +local function endEffect(st) st.func = nil end +local function incJt(st) st.jt = u8(st.jt + 1) end + +-- BGEffect_CheckBattleTurn: non-zero means "the side this effect is aimed at +-- is the player's". A struct's `turn` is BG_EFFECT_USER / BG_EFFECT_TARGET, +-- so the same effect id follows whichever battler is attacking. +function Pool:playerSide(st) + return bit.bxor(bit.band(self.env.battleTurn or 0, 1), st.turn) ~= 0 +end + +function Pool:sideKey(st) + return self:playerSide(st) and "player" or "enemy" +end + +-- BGEffect_CheckFlyDigStatus: zero means "on the field". +function Pool:flyDig(st) + local flying = self.env.flying or {} + return flying[self:sideKey(st)] and true or false +end + +function Pool:clearLYOverrides(value) + value = value or 0 + for row = 0, SCREEN_ROWS do self.lyBackup[row] = value end +end + +-- BattleBGEffect_SetLCDStatCustoms1: the window is the attacker's pic rows. +function Pool:setLCDStatCustoms1(register, st) + self.lcdc = register + if self:playerSide(st) then + self.lyStart, self.lyEnd = 0x2f, 0x5e + else + self.lyStart, self.lyEnd = 0x00, 0x36 + end +end + +-- engine/battle_anims/bg_effects.asm:2671 +function Pool:setLCDStatCustoms2(register, st) + self.lcdc = register + if self:playerSide(st) then + self.lyStart, self.lyEnd = 0x2d, 0x5e + else + self.lyStart, self.lyEnd = 0x00, 0x36 + end +end + +function Pool:resetLCDStatCustom(st) + self.lyStart, self.lyEnd = 0, 0 + self:clearLYOverrides(0) + self.lcdc = nil + endEffect(st) +end + +function Pool:resetVideoHRAM() + self.lcdc = nil + self.bgp, self.obp1 = NORMAL_PAL, NORMAL_PAL + self.lyStart, self.lyEnd = 0, 0 + self:clearLYOverrides(0) +end + +-- BGEffect_FillLYOverridesBackup: the same value on every scanline in the +-- window. `dec d; jr nz` after the first store, so a zero-width window would +-- run 256 times; the port refuses instead of wrapping the array. +function Pool:fillLY(value) + local count = u8(self.lyEnd - self.lyStart) + if count == 0 then count = 256 end + for i = 0, count - 1 do + local row = self.lyStart + i + if row > SCREEN_ROWS then break end + self.lyBackup[row] = u8(value) + end +end + +-- BGEffect_DisplaceLYOverridesBackup: the first `a` scanlines of the window +-- are scrolled to a blank part of the map ($90) and the rest are pushed down +-- by a + 1. That is what makes Withdraw and Dig look like the mon sinking +-- rather than sliding. +function Pool:displaceLY(a) + a = u8(a) + local span = u8(self.lyEnd - self.lyStart) + local rest = u8(span - a) + local row = self.lyStart + for _ = 1, (a == 0 and 256 or a) do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = 0x90 + row = row + 1 + end + local pushed = u8(0xff - a) + for _ = 1, (rest == 0 and 256 or rest) do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = pushed + row = row + 1 + end +end + +-- DeformScreen: a standing sine wave down the window. It walks the FIRST +-- $80 entries of wLYOverridesBackup by their low address byte and writes only +-- the ones inside the window -- `cp c / jr nc` skips while lyStart >= c and +-- `cp c / jr c` skips once lyEnd < c, so the row written is strictly +-- lyStart < row <= lyEnd -- but the phase advances on EVERY iteration, window +-- or not. So where the window sits decides which part of the wave lands on +-- it, and two effects with the same amplitude and offset but different +-- windows do not look alike. +-- +-- `lb de, d, e` puts the AMPLITUDE in d and the phase step in e. +function Pool:deformScreen(amplitude, offset) + local progress = 0 + for row = 0, 0x7f do + if self.lyStart < row and row <= self.lyEnd and row <= SCREEN_ROWS then + self.lyBackup[row] = sine(progress, amplitude) + end + progress = u8(progress + offset) + end +end + +-- InitSurfWaves: the same wave, into the $40-byte wSurfWaveBGEffect ring +-- rather than the overrides themselves. Surf rotates that ring a step a frame +-- and copies it out, which is what makes the water ROLL instead of standing +-- still the way DeformScreen's does. +Pool.SURF_WAVE_LENGTH = 0x40 + +function Pool:initSurfWaves(amplitude, offset) + local progress = 0 + self.surfWave = {} + for index = 0, Pool.SURF_WAVE_LENGTH - 1 do + self.surfWave[index] = sine(progress, amplitude) + progress = u8(progress + offset) + end +end + +-- BattleBGEffect_Surf's `.RotatewSurfWaveBGEffect`: rotate the ring left one, +-- then paint scanlines $00-$5e from it -- zero at and below lyStart, the ring +-- (wrapping every $40 rows) above it. The ring index advances on every +-- scanline including the zeroed ones, so the wave keeps its phase across the +-- boundary. +function Pool:rotateSurfWave() + local wave = self.surfWave + if not wave then return end + local first = wave[0] + for index = 0, Pool.SURF_WAVE_LENGTH - 2 do + wave[index] = wave[index + 1] + end + wave[Pool.SURF_WAVE_LENGTH - 1] = first + local ring = 0 + for row = 0, 0x5e do + local value = 0 + if self.lyStart < row then value = wave[ring] end + if row <= SCREEN_ROWS then self.lyBackup[row] = u8(value) end + ring = bit.band(ring + 1, Pool.SURF_WAVE_LENGTH - 1) + end +end + +-- DeformWater: `count` PAIRS of scanlines either side of a centre at +-- lyStart + `progress`, each pair taking the next step of a sine whose angle +-- climbs by 4 a pair. Both walkers start on the centre row, so it is written +-- twice and the figure is symmetric about it. The two bounds checks are not +-- the same test: the downward walker stops once lyEnd < its row, the upward +-- one once lyStart >= its row. +function Pool:deformWater(count, amplitude, offset, progress) + local down = self.lyStart + (progress or 0) + local up = down + local angle = u8(offset) + for _ = 1, u8(count) do + local value = sine(angle, amplitude) + if self.lyEnd >= down then + if down >= 0 and down <= SCREEN_ROWS then self.lyBackup[down] = value end + down = down + 1 + end + if self.lyStart < up then + if up >= 0 and up <= SCREEN_ROWS then self.lyBackup[up] = value end + up = up - 1 + end + angle = u8(angle + 4) + end +end + +-- BattleBGEffect_WavyScreenFX: rotate the window's overrides up one row, the +-- old top row wrapping around to the bottom. Every wobble effect is +-- DeformScreen once to lay the wave down and then this, once a frame, to make +-- it travel. +function Pool:wavyScreenFX() + local span = u8(self.lyEnd - self.lyStart) + if span == 0 then return end + local first = self.lyBackup[self.lyStart] or 0 + for i = 0, span - 1 do + local row = self.lyStart + i + if row > SCREEN_ROWS then break end + self.lyBackup[row] = self.lyBackup[row + 1] or 0 + end + local last = self.lyStart + span + if last <= SCREEN_ROWS then self.lyBackup[last] = first end +end + +-- BattleBGEffect_GetFirstDMGPal / GetNextDMGPal walking a `dc` list. +-- $ff ends the effect (returns nil); $fe restarts the list from the top. +local function nextPal(st, pals) + local index = st.param + st.param = u8(st.param + 1) + local value = pals[index + 1] + if value == nil or value == 0xff then return nil end + if value == 0xfe then + -- Rewind and hand back the list's first entry. + st.param = 0 + value = pals[1] + end + return value +end + +-- BattleBGEffect_GetNthDMGPal: JT doubles as a per-step frame counter, and it +-- is reloaded from the struct's `turn` -- so the SAME field is the flash speed +-- here and the battler side everywhere else. +local function nthPal(st, pals) + if st.jt ~= 0 then + st.jt = st.jt - 1 + local index = st.param + local value = pals[index + 1] + if value == nil or value == 0xff then return nil end + if value == 0xfe then + st.param = 0 + value = pals[1] + end + return value + end + st.jt = st.turn + return nextPal(st, pals) +end + +-------------------------------------------------------------------------- +-- The effects (BattleBGEffects jumptable) +-------------------------------------------------------------------------- + +local E = {} + +E.BATTLE_BG_EFFECT_END = function(_, st) endEffect(st) end + +-- BattleBGEffect_FlashContinue: `turn` is the flash duration, `param` the +-- number of flashes left, and the two palettes alternate. +local function flash(self, st, pals) + if st.jt ~= 0 then + st.jt = st.jt - 1 + return + end + st.jt = st.turn + if st.param == 0 then + endEffect(st) + return + end + st.param = u8(st.param - 1) + self.bgp = pals[bit.band(st.param, 1) + 1] +end + +E.BATTLE_BG_EFFECT_FLASH_INVERTED = function(self, st) + flash(self, st, { dc(3, 2, 1, 0), dc(0, 1, 2, 3) }) +end + +E.BATTLE_BG_EFFECT_FLASH_WHITE = function(self, st) + flash(self, st, { dc(3, 2, 1, 0), dc(0, 0, 0, 0) }) +end + +local WHITE_HUES = { dc(3, 2, 1, 0), dc(3, 2, 0, 0), dc(3, 1, 0, 0), 0xff } +local BLACK_HUES = { dc(3, 2, 1, 0), dc(3, 3, 1, 0), dc(3, 3, 2, 0), 0xff } +local ALTERNATE_HUES = { + dc(3, 2, 1, 0), dc(3, 3, 2, 0), dc(3, 3, 3, 0), dc(3, 3, 2, 0), + dc(3, 2, 1, 0), dc(2, 1, 0, 0), dc(1, 0, 0, 0), dc(2, 1, 0, 0), 0xfe, +} + +E.BATTLE_BG_EFFECT_WHITE_HUES = function(self, st) + local value = nthPal(st, WHITE_HUES) + if not value then + endEffect(st) + return + end + self.bgp = value +end + +E.BATTLE_BG_EFFECT_BLACK_HUES = function(self, st) + local value = nthPal(st, BLACK_HUES) + if not value then + endEffect(st) + return + end + self.bgp = value +end + +E.BATTLE_BG_EFFECT_ALTERNATE_HUES = function(self, st) + local value = nthPal(st, ALTERNATE_HUES) + if not value then + endEffect(st) + return + end + self.bgp, self.obp1 = value, value +end + +local OB_GRAY_YELLOW = { dc(3, 2, 1, 0), dc(2, 1, 0, 0), 0xfe } +local OB_MID_GRAY_YELLOW = { dc(3, 2, 1, 0), dc(3, 1, 2, 0), 0xfe } +local BG_INVERTED = { dc(0, 1, 2, 3), dc(1, 2, 0, 3), dc(2, 0, 1, 3), 0xfe } + +E.BATTLE_BG_EFFECT_CYCLE_OBPALS_GRAY_AND_YELLOW = function(self, st) + local value = nthPal(st, OB_GRAY_YELLOW) + if value then self.obp0 = value end +end + +E.BATTLE_BG_EFFECT_CYCLE_MID_OBPALS_GRAY_AND_YELLOW = function(self, st) + local value = nthPal(st, OB_MID_GRAY_YELLOW) + if value then self.obp0 = value end +end + +E.BATTLE_BG_EFFECT_CYCLE_BGPALS_INVERTED = function(self, st) + local value = nthPal(st, BG_INVERTED) + if value then self.bgp = value end +end + +-- engine/battle_anims/bg_effects.asm:343 +E.BATTLE_BG_EFFECT_HIDE_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self.hidden[self:sideKey(st)] = true + elseif jt >= 1 and jt <= 3 then + incJt(st) + elseif jt == 4 then + endEffect(st) + end +end + +-- BattleBGEffect_RunPicResizeScript: rows of {size, base tile, coord slot}, +-- $ff ending, $fe clearing a box, $fd skipping the draw. The sizes are the +-- six BG squares (6x6, 4x4, 2x2 for the player; 7x7, 5x5, 3x3 for the enemy), +-- which is how a mon grows into or shrinks out of the field. Only the size +-- matters to this port -- the tile ids and the coord slot are the same pic in +-- the same box -- so the script is followed for its TIMING and its scale. +local PIC_RESIZE = { + -- BattleBGEffect_ShowMon + showPlayer = { 0 }, + showEnemy = { 3 }, + -- BattleBGEffect_EnterMon + enterPlayer = { 2, 1, 0 }, + enterEnemy = { 5, 4, 3 }, + -- BattleBGEffect_ReturnMon: each step is preceded by a box clear, which is + -- the -2 row, and the last -3 row leaves the field empty. + returnPlayer = { 0, 1, 2, false }, + returnEnemy = { 3, 4, 5, false }, +} + +local function runPicResize(self, st, script) + local side = self:sideKey(st) + local jt = st.jt + if jt == 0 then + local step = script[st.param + 1] + st.param = u8(st.param + 1) + if step == nil then + self.picSize[side] = nil + endEffect(st) + return + end + if step == false then + self.picSize[side] = nil + self.hidden[side] = true + else + self.picSize[side] = step + self.hidden[side] = false + end + self.liftedRows[side] = nil + incJt(st) + elseif jt >= 1 and jt <= 2 then + incJt(st) + elseif jt == 3 then + st.jt = 0 + elseif jt == 4 then + self.picSize[side] = nil + endEffect(st) + end +end + +E.BATTLE_BG_EFFECT_SHOW_MON = function(self, st) + if self:flyDig(st) then + endEffect(st) + return + end + runPicResize(self, st, self:playerSide(st) + and PIC_RESIZE.showPlayer or PIC_RESIZE.showEnemy) +end + +E.BATTLE_BG_EFFECT_ENTER_MON = function(self, st) + runPicResize(self, st, self:playerSide(st) + and PIC_RESIZE.enterPlayer or PIC_RESIZE.enterEnemy) +end + +E.BATTLE_BG_EFFECT_RETURN_MON = function(self, st) + runPicResize(self, st, self:playerSide(st) + and PIC_RESIZE.returnPlayer or PIC_RESIZE.returnEnemy) +end + +-- BattleBGEffect_RemoveMon slides the pic's tilemap one column a frame +-- towards the edge it came from, eight or nine columns' worth. +E.BATTLE_BG_EFFECT_REMOVE_MON = function(self, st) + local side = self:sideKey(st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.param = self:playerSide(st) and 9 or 8 + elseif jt == 1 then + self.slide[side] = self.slide[side] + (self:playerSide(st) and -8 or 8) + incJt(st) + st.param = u8(st.param - 1) + elseif jt == 2 or jt == 3 then + incJt(st) + elseif jt == 4 then + if st.param == 0 then + self.slide[side] = 0 + self.hidden[side] = true + endEffect(st) + return + end + st.jt = 1 + end +end + +-- The two battler-pic objects: the animation borrows the mon's own tiles as +-- an OBJ so it can be moved without touching the tilemap. +local function battlerObj(self, st, objectPlayer, objectEnemy, rows) + local jt = st.jt + if jt == 0 then + if self:flyDig(st) then + endEffect(st) + return + end + incJt(st) + local player = self:playerSide(st) + self.spawns[#self.spawns + 1] = { + object = player and objectPlayer or objectEnemy, + x = player and (6 * 8) or (16 * 8 + 4), + y = 8 * 8, + param = 0, + } + elseif jt == 1 then + incJt(st) + -- engine/battle_anims/bg_effects.asm:448-465: the rows the OBJ now covers + -- come out of the tilemap, and .five never puts them back. + self.liftedRows[self:sideKey(st)] = rows[self:sideKey(st)] + elseif jt >= 2 and jt <= 4 then + incJt(st) + elseif jt == 5 then + endEffect(st) + end +end + +E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st) + battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW", + "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", + { player = { 0, 1 }, enemy = { 6, 1 } }) +end + +E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st) + battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW", + "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", + { player = { 0, 2 }, enemy = { 5, 2 } }) +end + +-- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler +-- (the struct's side) rather than to the whole background, which is what the +-- per-mon fades want; the port keeps that and leaves wBGP alone. +local function rapidCyclePals(self, st, pals) + local jt = st.jt + if jt == 0 then + -- engine/battle_anims/bg_effects.asm:2480-2494 + incJt(st) + st.side = self:sideKey(st) + st.turn = st.param + st.param = 0 + return + end + local side = st.side or self:sideKey(st) + if jt == 1 then + if bit.band(st.turn, 0xf) ~= 0 then + st.turn = u8(st.turn - 1) + return + end + -- The low nybble is reloaded from the high one, which is the step delay. + st.turn = bit.bor(swap(st.turn), st.turn) + local value = nextPal(st, pals) + if value == nil then + -- engine/battle_anims/bg_effects.asm:2515-2519 + st.param = u8(st.param - 1) + return + end + self.monShade[side] = value + return + end + self.monShade[side] = NORMAL_PAL + endEffect(st) +end + +local RAPID_PALS = { + BATTLE_BG_EFFECT_RAPID_FLASH = { 0xe4, 0x6c, 0xfe }, + BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT = { 0xe4, 0x90, 0x40, 0xff }, + BATTLE_BG_EFFECT_FADE_MON_TO_BLACK = { 0xe4, 0xf8, 0xfc, 0xff }, + BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT_REPEATING = { 0xe4, 0x90, 0x40, 0x90, 0xfe }, + BATTLE_BG_EFFECT_FADE_MON_TO_BLACK_REPEATING = { 0xe4, 0xf8, 0xfc, 0xf8, 0xfe }, + BATTLE_BG_EFFECT_CYCLE_MON_LIGHT_DARK_REPEATING = + { 0xe4, 0xf8, 0xfc, 0xf8, 0xe4, 0x90, 0x40, 0x90, 0xfe }, + BATTLE_BG_EFFECT_FLASH_MON_REPEATING = { 0xe4, 0xfc, 0xe4, 0x00, 0xfe }, + BATTLE_BG_EFFECT_FADE_MON_TO_WHITE_WAIT_FADE_BACK = { + 0xe4, 0x90, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x40, 0x90, 0xe4, 0xff, + }, + BATTLE_BG_EFFECT_FADE_MON_FROM_WHITE = { 0x00, 0x40, 0x90, 0xe4, 0xff }, +} + +for name, pals in pairs(RAPID_PALS) do + E[name] = function(self, st) rapidCyclePals(self, st, pals) end +end + +-- BattleBGEffect_FadeMonsToBlackRepeating fades BOTH battlers, on opposite +-- halves of the same four-step ramp. +local FADE_BOTH = { 0xe4, 0xe4, 0xf8, 0x90, 0xfc, 0x40, 0xf8, 0x90 } + +E.BATTLE_BG_EFFECT_FADE_MONS_TO_BLACK_REPEATING = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.param = 0 + return + end + if jt == 1 then + local age = st.param + st.param = u8(st.param + 1) + if bit.band(age, 7) ~= 0 then return end + -- Bits 3-4 pick the pair, doubled into a row index. + local index = bit.rshift(bit.band(age, 0x18), 3) * 2 + local first = FADE_BOTH[index + 1] or NORMAL_PAL + local second = FADE_BOTH[index + 2] or NORMAL_PAL + if self:playerSide(st) then + self.monShade.player, self.monShade.enemy = first, second + else + self.monShade.enemy, self.monShade.player = first, second + end + return + end + self.monShade.player, self.monShade.enemy = NORMAL_PAL, NORMAL_PAL + endEffect(st) +end + +-- BattleBGEffects_GetShakeAmount. JT is the total frame count, PARAM's low +-- nybble the countdown to the next flip (reloaded from its high nybble) and +-- `turn` the amplitude, negated on every flip. Returns nil once it is done. +local function shakeAmount(self, st) + if st.jt == 0 then + endEffect(st) + return nil + end + st.jt = st.jt - 1 + if bit.band(st.param, 0xf) ~= 0 then + st.param = u8(st.param - 1) + return st.turn + end + st.param = bit.bor(swap(st.param), st.param) + st.turn = u8(-st.turn) + return st.turn +end + +E.BATTLE_BG_EFFECT_SHAKE_SCREEN_X = function(self, st) + self.scx = shakeAmount(self, st) or 0 +end + +E.BATTLE_BG_EFFECT_SHAKE_SCREEN_Y = function(self, st) + self.scy = shakeAmount(self, st) or 0 +end + +-- Rollout shakes vertically and hands the negated amount to the first anim +-- object's Y offset, so the boulder rides the shake instead of floating over +-- it. The cart's extra DelayFrame here is what makes Rollout's animation run +-- at half speed; RunBattleAnimScript skips its own frame delay to compensate. +E.BATTLE_BG_EFFECT_ROLLOUT = function(self, st) + local amount = shakeAmount(self, st) + if amount == nil or bit.band(amount, 0x80) ~= 0 then amount = 0 end + self.scy = amount + self.rolloutYOffset = u8(-amount) +end + +E.BATTLE_BG_EFFECT_WOBBLE_SCREEN = function(self, st) + if st.param >= 0x40 then + self.scx = 0 + return + end + self.scx = sine(st.param, 6) + st.param = u8(st.param + 2) +end + +-- Withdraw: a growing number of the attacker's scanlines are pushed off, so +-- the mon appears to pull into its shell. PARAM's low six bits are how far +-- to go and its top two the step. +E.BATTLE_BG_EFFECT_WITHDRAW = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 1 + elseif jt == 1 then + local limit = bit.band(st.param, 0x3f) + if st.turn >= limit then return end + self:displaceLY(st.turn) + local step = bit.band(bit.rshift(st.param, 6), 3) + st.turn = u8(st.turn + step) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Dig: the same displacement, but it pauses and then eats the pic two +-- scanlines at a time until the whole window is gone. +E.BATTLE_BG_EFFECT_DIG = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 2 + st.param = 0 + elseif jt == 1 then + if st.param ~= 0 then + st.param = u8(st.param - 1) + return + end + st.param = 0x10 + incJt(st) + elseif jt == 2 then + local span = u8(self.lyEnd - self.lyStart) - 1 + if span < st.turn then return end + -- Every eighth scanline the effect steps back a state, which is the + -- pause between digs. + if bit.band(st.turn, 7) == 0 then st.jt = u8(st.jt - 1) end + self:displaceLY(st.turn) + st.turn = u8(st.turn + 2) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +-- Tackle: the attacker's rows slide eight pixels towards the target and back. +-- `turn` is the signed step and `param` the distance travelled so far. +local function tackleMoveForward(self, st) + if st.param == u8(-8) or st.param == 8 then incJt(st) end + self:fillLY(st.param) + st.param = u8(st.param + st.turn) +end + +local function tackleReturn(self, st) + if st.param == 0 then incJt(st) end + self:fillLY(st.param) + st.param = u8(st.param + u8(-st.turn)) +end + +local function tackleInit(self, st, backwards) + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + -- SCX scrolls the BACKGROUND, so a negative value moves the mon RIGHT: the + -- player's back pic steps towards the enemy on -2, not +2. + local forward = self:playerSide(st) and u8(-2) or 2 + if backwards then forward = self:playerSide(st) and 2 or u8(-2) end + st.param = 0 + st.turn = forward +end + +E.BATTLE_BG_EFFECT_TACKLE = function(self, st) + local jt = st.jt + if jt == 0 then + tackleInit(self, st, false) + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 2 then + tackleReturn(self, st) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_VITAL_THROW = function(self, st) + local jt = st.jt + if jt == 0 then + tackleInit(self, st, true) + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 3 then + tackleReturn(self, st) + elseif jt == 4 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_BETA_PURSUIT = function(self, st) + local jt = st.jt + if jt == 0 then + tackleInit(self, st, true) + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 2 then + tackleReturn(self, st) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +-- engine/battle_anims/bg_effects.asm:1444 +E.BATTLE_BG_EFFECT_BODY_SLAM = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms2("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.param = 0 + st.turn = self:playerSide(st) and u8(-2) or 2 + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 2 then + tackleReturn(self, st) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_WOBBLE_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.param = 0 + elseif jt == 1 then + self:fillLY(sine(st.param, 8)) + st.param = u8(st.param + 4) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Always the player's rows, and the window is written directly rather than +-- through SetLCDStatCustoms1 -- this is the wobble the player's own mon does +-- when it is confused, whoever is attacking. +E.BATTLE_BG_EFFECT_WOBBLE_PLAYER = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self.lcdc = "SCX" + self.lyStart, self.lyEnd = 0, 0x37 + st.param = 0 + elseif jt == 1 then + if st.param >= 0x40 then + self:resetLCDStatCustom(st) + return + end + self:fillLY(sine(st.param, 6)) + st.param = u8(st.param + 2) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Two sines an octave apart, which is what makes Flail read as thrashing +-- rather than swaying. +E.BATTLE_BG_EFFECT_FLAIL = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn, st.param = 0, 0 + elseif jt == 1 then + local wide = sine(st.param, 6) + local narrow = sine(st.turn, 2) + self:fillLY(u8(wide + narrow)) + st.turn = u8(st.turn + 8) + st.param = u8(st.param + 2) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_VIBRATE_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 1 + st.param = 0x20 + elseif jt == 1 then + if st.param == 0 then + self:resetLCDStatCustom(st) + return + end + st.param = u8(st.param - 1) + -- Flips on the even frames only, so it buzzes at 30 Hz rather than 60. + if bit.band(st.param, 1) ~= 0 then return end + st.turn = u8(-st.turn) + self:fillLY(st.turn) + end +end + +-- BounceDown: the attacker drops in on a cosine and settles, using the same +-- scanline displacement Withdraw does. +E.BATTLE_BG_EFFECT_BOUNCE_DOWN = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms2("SCY", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 1 + st.param = 0x20 + elseif jt == 1 then + if st.turn >= 0x38 then return end + local height = u8(cosine(st.param, 0x10) + 0x10) + self:displaceLY(u8(st.turn + height)) + st.param = u8(st.param + 2) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-------------------------------------------------------------------------- +-- The screen-wide deformations +-------------------------------------------------------------------------- +-- +-- These thirteen are the ones that write a DIFFERENT value to every scanline +-- rather than the same one to a band, so they all sit on DeformScreen, +-- DeformWater or the surf ring above. The shape of each is the ASM's; what +-- the port cannot reproduce is the CGB writing rSCX mid-frame at sub-pixel +-- timing, and none of these depend on that -- they depend on the ARRAY, which +-- is modelled exactly. + +-- Surf. `.zero` lays a 2-amplitude wave into the ring and falls through to +-- `.one` on the same frame (ASM fallthrough, not a jumptable branch), and +-- `.one` waits for hLCDCPointer: engine/battle_anims/functions.asm:1158. +E.BATTLE_BG_EFFECT_SURF = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:initSurfWaves(2, 2) + jt = 1 + end + if jt == 1 then + if not self.lcdc then return end + self:rotateSurfWave() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Whirlpool: the wave covers the WHOLE screen ($00-$5e) rather than one +-- battler's rows, and it scrolls vertically (rSCY), so the water rolls +-- top to bottom behind both mons. +E.BATTLE_BG_EFFECT_WHIRLPOOL = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self.lcdc = "SCY" + self.lyStart, self.lyEnd = 0, 0x5e + self:deformScreen(2, 2) + elseif jt == 1 then + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- The three water effects, which the Surf animation drives as a set: START +-- opens the window and ends itself immediately, WATER does the work, END puts +-- the registers back. +E.BATTLE_BG_EFFECT_START_WATER = function(self, st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + endEffect(st) +end + +-- WATER is the one effect whose three struct fields are all something else: +-- PARAM is the sine phase (climbing 4 a frame), BATTLE_TURN is a frame +-- counter that doubles as the amplitude, and JT_INDEX is the Y position the +-- deformation is centred on. +-- +-- `ld a, [hl]` then `inc [hl]` twice leaves `a` holding the PRE-increment +-- turn, and that is the count DeformWater is called with -- so the figure +-- grows two scanlines a frame from nothing until the counter passes $20. +E.BATTLE_BG_EFFECT_WATER = function(self, st) + local offset = st.param + st.param = u8(st.param + 4) + -- (0xff XOR the high nibble) + 4: the amplitude SHRINKS as the counter + -- climbs, so the wave is widest when it first appears. + local amplitude = u8(bit.bxor(bit.rshift(bit.band(st.turn, 0xf0), 4), 0xff) + 4) + local progress = st.jt + local count = st.turn + if count >= 0x20 then + self:clearLYOverrides(0) + endEffect(st) + return + end + st.turn = u8(st.turn + 2) + self:deformWater(count, amplitude, offset, progress) +end + +E.BATTLE_BG_EFFECT_END_WATER = function(self, st) + self:resetLCDStatCustom(st) +end + +-- Psychic is hardcoded to the whole screen ($00-$5f) whichever side used it, +-- and only travels every FOURTH frame (`and $3 / ret nz`), which is what makes +-- it a slow ripple rather than Teleport's shimmer. +E.BATTLE_BG_EFFECT_PSYCHIC = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self.lcdc = "SCX" + self.lyStart, self.lyEnd = 0, 0x5f + self:deformScreen(6, 5) + st.param = 0 + elseif jt == 1 then + local counter = st.param + st.param = u8(st.param + 1) + if bit.band(counter, 3) ~= 0 then return end + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Teleport: the same wave as Psychic but only over the user's own rows, and +-- travelling every frame. +E.BATTLE_BG_EFFECT_TELEPORT = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self:deformScreen(6, 5) + elseif jt == 1 then + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Night Shade takes its phase step from the struct's PARAM, so the same +-- effect id gives a long slow roll or a tight ripple depending on what the +-- script queued it with. +E.BATTLE_BG_EFFECT_NIGHT_SHADE = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self:deformScreen(2, st.param) + elseif jt == 1 then + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Double Team's afterimage: alternate scanlines are pushed +n and -n, so the +-- pic reads as two copies of itself a few pixels apart. `.UpdateLYOverrides` +-- writes the pair (e, -e) down the window and, on an odd-height window, +-- repeats `e` on the last row -- `srl a` leaves the odd bit in carry and +-- `ret nc` is what skips that store on an even one. +local function doubleTeamOverrides(self, value) + local e = u8(value) + local d = u8(-e) + local span = u8(self.lyEnd - self.lyStart) + local pairs_ = bit.rshift(span, 1) + local odd = bit.band(span, 1) ~= 0 + local row = self.lyStart + for _ = 1, pairs_ do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = e + row = row + 1 + if row > SCREEN_ROWS then return end + self.lyBackup[row] = d + row = row + 1 + end + if odd and row <= SCREEN_ROWS then self.lyBackup[row] = e end +end + +E.BATTLE_BG_EFFECT_DOUBLE_TEAM = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 0 + elseif jt == 1 then + -- Split apart, a pixel a frame, to $10. + if st.param >= 0x10 then + incJt(st) + return + end + local value = st.param + st.param = u8(st.param + 1) + doubleTeamOverrides(self, value) + elseif jt == 2 then + -- Hold, wobbling about the current separation. This state never advances + -- itself; the script's own `incbgeffect` is what moves it on. + local wobble = u8(sine(st.turn, 2) + st.param) + doubleTeamOverrides(self, wobble) + st.turn = u8(st.turn + 4) + elseif jt == 3 then + -- Come back together. The test is `cp $ff`, so a PARAM that started at 0 + -- underflows to $ff and stops there rather than at zero. + if st.param == 0xff then + incJt(st) + return + end + local value = st.param + st.param = u8(st.param - 1) + doubleTeamOverrides(self, value) + elseif jt == 5 then + self:resetLCDStatCustom(st) + end + -- jt 4 is a bare `ret`: the gap the script sits in between the two halves. +end + +-- Acid Armor: the wave is laid down once and then the whole window is scrolled +-- DOWN one scanline a frame, with a blank row ($90) fed in at the top -- so +-- the mon melts into the floor instead of wobbling in place. The two +-- fix-ups at the bottom clear the last two rows once their values are large +-- enough to be showing the pic's own bottom edge. +E.BATTLE_BG_EFFECT_ACID_ARMOR = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self:deformScreen(2, st.param) + self.lyBackup[self.lyEnd] = 0 + self.lyBackup[self.lyEnd - 1] = 0 + elseif jt == 1 then + for row = self.lyEnd, self.lyStart + 1, -1 do + self.lyBackup[row] = self.lyBackup[row - 1] or 0 + end + self.lyBackup[self.lyStart] = 0x90 + local last = self.lyBackup[self.lyEnd] or 0 + if last >= 1 and last ~= 0x90 then self.lyBackup[self.lyEnd] = 0 end + local penultimate = self.lyBackup[self.lyEnd - 1] or 0 + if penultimate >= 2 and penultimate ~= 0x90 then + self.lyBackup[self.lyEnd - 1] = 0 + end + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Wave Deform: the amplitude ramps up to $20 in state 1 and back down to 0 in +-- state 2, at a fixed phase step of 4. Neither ramp advances the state on its +-- own -- the script does -- so how far it gets is the script's business. +E.BATTLE_BG_EFFECT_WAVE_DEFORM_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + elseif jt == 1 then + if st.param >= 0x20 then return end + local amplitude = st.param + st.param = u8(st.param + 1) + self:deformScreen(amplitude, 4) + elseif jt == 2 then + if st.param == 0 then + self:resetLCDStatCustom(st) + return + end + local amplitude = st.param + st.param = u8(st.param - 1) + self:deformScreen(amplitude, 4) + end +end + +-- The two beta send-outs are `; unused` on the cart -- nothing queues them -- +-- but they are in the jumptable, so a mod or a hand-written script can, and +-- an unimplemented entry would sit in the pool forever. +-- +-- MON1 writes rBGP per scanline rather than a scroll register: every other +-- row of the window steps through $00 (all white), $40, $90 and $e4 (normal), +-- eight frames apart, so the pic fades in through a venetian blind. +local BETA_SEND_OUT_PALS = { 0x00, 0x40, 0x90, 0xe4 } + +-- `.SetLYOverridesBackup`: every SECOND scanline, (lyEnd - lyStart) / 2 times. +local function betaBlind(self, value) + local count = bit.rshift(u8(self.lyEnd - self.lyStart), 1) + local row = self.lyStart + for _ = 1, count do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = u8(value) + row = row + 2 + end +end + +-- `.GetLYOverride`: PARAM counts up and its top bits index the palette list, +-- so each entry is held eight frames. Past the end it returns nil, which is +-- the `cp $ff` the caller branches on. +local function betaPal(st) + local index = bit.rshift(st.param, 3) + st.param = u8(st.param + 1) + return BETA_SEND_OUT_PALS[index + 1] +end + +E.BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1 = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0xe4) + self:setLCDStatCustoms1("BGP", st) + self.lyEnd = u8(self.lyEnd + 1) + for row = self.lyStart, self.lyEnd - 1 do + if row > SCREEN_ROWS then break end + self.lyBackup[row] = 0 + end + st.param = 0 + -- `.zero` falls into `.one`, which is a bare ret. + elseif jt == 2 then + local value = betaPal(st) + if value then + betaBlind(self, value) + return + end + st.param = 0 + self.lyStart = u8(self.lyStart + 1) + incJt(st) + elseif jt == 3 then + local value = betaPal(st) + if not value then + incJt(st) + return + end + betaBlind(self, value) + -- The second pass also fills the row the blind skipped at the bottom. + self.lyBackup[self.lyEnd - 1] = u8(value) + elseif jt == 5 then + self:resetVideoHRAM() + endEffect(st) + end + -- jt 1 and 4 are bare rets. +end + +-- MON2 is a plain DeformScreen whose amplitude and phase step are the SAME +-- value, counted down from $40 in eighths -- so the wobble starts at 8 and +-- unwinds to nothing. +E.BATTLE_BG_EFFECT_BETA_SEND_OUT_MON2 = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + st.turn = 0x40 + elseif jt == 1 then + if st.turn == 0 then + self:resetLCDStatCustom(st) + return + end + local value = st.turn + st.turn = u8(st.turn - 1) + -- `ld a, [hl] / dec [hl] / srl a x3`: the PRE-decrement value, shifted. + local amount = bit.band(bit.rshift(value, 3), 0x0f) + self:deformScreen(amount, amount) + end +end + +-- Nothing is left unmodelled. The name stays so a caller (and the tests) can +-- still ask, and so the answer is checkable rather than a claim in a comment. +local UNMODELLED = {} +local UNMODELLED_SET = {} +for _, name in ipairs(UNMODELLED) do UNMODELLED_SET[name] = true end + +local DROPPED = {} +BgEffects.DROPPED = DROPPED +BgEffects.strict = os.getenv("POKEPORT_DEV") == "1" + +local function dropUnknown(st) + local name = st.func + endEffect(st) + if UNMODELLED_SET[name] then return end + if not DROPPED[name] then + DROPPED[name] = true + DROPPED[#DROPPED + 1] = name + end + if BgEffects.strict then + error("BgEffects: no entry for " .. tostring(name)) + end +end + +-------------------------------------------------------------------------- + +-- ExecuteBGEffects: one pass over the five structs. +function Pool:playFrame() + for slot = 1, NUM_EFFECTS do + local st = self.effects[slot] + if st.func then + local fn = E[st.func] + if fn then + fn(self, st) + else + -- An id with no entry would otherwise sit in the pool forever and + -- keep the animation from ending. + dropUnknown(st) + end + end + end +end + +function Pool:takeSpawns() + local spawns = self.spawns + self.spawns = {} + return spawns +end + +BgEffects.EFFECTS = E +BgEffects.NUM_EFFECTS = NUM_EFFECTS +BgEffects.NORMAL_PAL = NORMAL_PAL +BgEffects.SCREEN_ROWS = SCREEN_ROWS +BgEffects.UNMODELLED = UNMODELLED + +return BgEffects diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index bbd959cfd..975e9022b 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -5705,7 +5705,11 @@ function RomImporter:draw() if link.url==DONATE_URL then paypalLink=link else footerLinks[#footerLinks+1]=link end end - local creditH = self.hintFont:getHeight() + local creditText = "Created by " .. credit.by + .. "\nBased on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC" + .. "\n(https://github.com/bryanthaboi/gen1recomp)" + local _, creditLines = self.hintFont:getWrap(creditText, appW) + local creditH = math.max(1, #creditLines) * self.hintFont:getHeight() local donateH = self.hintFont:getHeight() + 10 * s local chipsH = (#footerLinks > 0) and (8 * s + donateH) or 0 local footerH = 10 * s + bcgDH + 6 * s + warningH + 8 * s + creditH + chipsH + 12 * s @@ -6240,7 +6244,7 @@ function RomImporter:draw() love.graphics.setFont(self.hintFont) local creditY = warningY + warningH + 8 * s col(PAL.warning) - love.graphics.printf("Created by " .. credit.by, appX, creditY, appW, "center") + love.graphics.printf(creditText, appX, creditY, appW, "center") self.donateButtons = {} self.donateButton = nil if #footerLinks > 0 then diff --git a/tests/engine/gen2_native_bg_pool.lua b/tests/engine/gen2_native_bg_pool.lua new file mode 100644 index 000000000..ada94a3af --- /dev/null +++ b/tests/engine/gen2_native_bg_pool.lua @@ -0,0 +1,72 @@ +-- GSC register/picture traces against the native BgEffects API. No graphics, +-- game state, ROM, synthetic animation runner or replacement clock is used. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local Bg=require('src.battle.gen2.BgEffects') +local Ids=require('src.battle.Gen2BgEffectIds') +local Math=require('src.battle.Gen2AnimMath') +T.eq(Math.sine(16,32),32,'Q8.8 quarter-wave reaches its exact positive amplitude') +T.eq(Math.sine(48,32),224,'negative amplitude wraps as a hardware byte') +T.eq(Math.cosine(0,32),32,'cosine follows the cartridge quarter-wave shift') +T.eq(Math.sra(0x80),0xc0,'arithmetic right shift preserves a negative byte sign') +T.eq(Math.sra(0xff),0xff,'minus one remains minus one after arithmetic shift') +T.eq(Math.sra(0x7f),0x3f,'positive arithmetic shift does not invent sign bits') +T.eq(Math.swap(0xab),0xba,'nibble swap preserves byte ordering') +for value=0,255 do T.eq(Math.swap(Math.swap(value)),value,'two nibble swaps recover the hardware byte')end + +for _,edition in ipairs({'gold','silver','crystal'})do + local order=Ids.forVersion(edition) + T.eq(#order,edition=='crystal'and 54 or 53,'edition owns its cartridge BG jump table') + T.eq(order[38],edition=='crystal'and'BATTLE_BG_EFFECT_BODY_SLAM'or'BATTLE_BG_EFFECT_WOBBLE_MON', + 'Crystal insertion does not misinterpret the Gold/Silver effect id') + T.eq(order[#order],'BATTLE_BG_EFFECT_WOBBLE_SCREEN','edition table terminates at the proper final effect') + local altered=Ids.forVersion(edition);altered[1]='changed' + T.eq(Ids.forVersion(edition)[1],'BATTLE_BG_EFFECT_END','one consumer cannot mutate another edition registry') + for index,name in ipairs(order)do + T.eq(type(Bg.EFFECTS[name]),'function','every cartridge BG id has an actual native state machine') + local pool=Bg.new({battleBgEffectOrder=order},{battleTurn=0}) + local entry=pool:queue(index-1,0,0,0) + T.eq(entry and entry.func,name,'raw cartridge id selects its actual edition state machine') + local before=#Bg.DROPPED + T.check(pcall(function()for _=1,8 do pool:playFrame()end end), + 'known native effect executes its initial state transitions') + T.eq(#Bg.DROPPED,before,'known cartridge effects never take the unknown-id fallback') + end +end + +-- BattleBGEffect_EnterMon's three four-frame squares. Player uses 2x2, +-- 4x4,6x6; enemy uses 3x3,5x5,7x7 (pic-size selectors2/1/0 and5/4/3). +for _,turn in ipairs({0,1})do + for _,side in ipairs({'player','enemy'})do + local sideFlag=side=='player'and(1-turn)or turn + local pool=Bg.new({}, {battleTurn=turn}) + pool:queue('BATTLE_BG_EFFECT_ENTER_MON',0,sideFlag,0) + for frame=1,13 do + pool:playFrame() + local stage=math.floor((frame-1)/4) + local expected=stage<3 and((side=='player'and 2 or 5)-stage)or nil + T.eq(pool.picSize[side],expected,'native entry follows its own picture-size trace') + T.eq(pool.hidden[side],false,'entry reveals the side selected by battle-turn XOR') + end + T.eq(pool:activeCount(),0,'entry terminates after its native final picture state') + end +end + +-- BODY_SLAM is Crystal's inserted $25 entry. The trace retains the exact +-- LCD window and byte displacement cited by Gen1Recomp bug2102. +local slam=Bg.new({battleBgEffectOrder=Ids.forVersion('crystal')},{battleTurn=0}) +slam:queue(0x25,0,1,0);slam:playFrame() +T.eq(slam.lcdc,'SCX','BODY_SLAM displaces the selected band horizontally') +T.eq(slam.lyStart,0x2d,'player band begins at the cartridge LCD boundary') +T.eq(slam.lyEnd,0x5f,'player band ends one row beyond the picture') +slam:playFrame();slam:playFrame() +T.eq(slam.lyBackup[0x30],0xfe,'native lunge writes the signed two-pixel displacement') + +local pool=Bg.new({}, {battleTurn=0}) +for _=1,5 do T.check(pool:queue('BATTLE_BG_EFFECT_FLASH_WHITE',0,0,0)~=nil,'native pool admits five hardware effect structs')end +T.eq(pool:queue('BATTLE_BG_EFFECT_FLASH_WHITE',0,0,0),nil,'full native pool refuses a sixth struct') +pool:reset();T.eq(pool:activeCount(),0,'native reset retires all effect owners') +T.eq(pool.bgp,0xe4,'native reset restores identity BG palette register') +T.eq(pool.scx,0,'native reset restores horizontal register') +T.eq(pool.scy,0,'native reset restores vertical register') +T.finish('native GSC BG registry, byte arithmetic and register/picture state machines') From 006eb84e8728a34f4dd3ef3e434cd2aeace4d80a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:35:56 +0100 Subject: [PATCH 067/187] Verify native footer retains original project credit and wrapped control spacing --- tests/engine/gen1_source_credit_footer.lua | 39 ++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/engine/gen1_source_credit_footer.lua diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua new file mode 100644 index 000000000..5ea342577 --- /dev/null +++ b/tests/engine/gen1_source_credit_footer.lua @@ -0,0 +1,39 @@ +-- Native launcher draw and scroll owners; only the graphics device is stubbed. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local run=T.sdk.loadNone({data=T.fixtures.fresh(),fs=T.sdk.memfs({})}) +local Importer=require('src.import.RomImporter') +local g=love.graphics +local dimensions,printf,setFont=g.getDimensions,g.printf,g.setFont +local width,height=960,720 +g.getDimensions=function()return width,height end +local printed,currentFont={},nil +g.setFont=function(font)currentFont=font;return setFont(font)end +g.printf=function(text,x,y,w,...) + if type(text)=='string'and text:find('Based on the Pokemon Gen 1',1,true)then + printed[#printed+1]={text=text,x=x,y=y,width=w,font=currentFont} + end + return printf(text,x,y,w,...) +end +local ui=Importer.new(function()end) +for _,size in ipairs({{960,720},{480,360},{1280,960}})do + width,height=size[1],size[2] + for _,tab in ipairs({'red','gold','crystal','emerald'})do + ui.tab=tab;printed={} + T.check(pcall(ui.draw,ui),'actual native launcher still draws with wrapped attribution') + local line=printed[#printed] + T.check(line~=nil,'native launcher renders the original project credit') + if line then + T.check(line.text:find('by BOIS CLUB GAMES, LLC',1,true)~=nil,'credit retains original rights holder') + T.check(line.text:find('https://github.com/bryanthaboi/gen1recomp',1,true)~=nil,'credit retains source project link') + local _,lines=line.font:getWrap(line.text,line.width) + local bottom=line.y+math.max(1,#lines)*line.font:getHeight() + for _,button in ipairs(ui.donateButtons or {})do + T.check(button.y>=bottom,'native footer reserves the full wrapped credit before donation controls') + end + end + end +end +g.getDimensions,g.printf,g.setFont=dimensions,printf,setFont +run.release() +T.finish('Gen1 source attribution through native launcher footer') From 5611ee645361eca2aa560d8bfe8c373b892e709e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:38:03 +0100 Subject: [PATCH 068/187] Provide the external platform boundary for native footer draw and check real SDK entry --- tests/engine/gen1_source_credit_footer.lua | 2 ++ tests/engine/gen2_bg_sdk.lua | 35 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tests/engine/gen2_bg_sdk.lua diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua index 5ea342577..85ded0eda 100644 --- a/tests/engine/gen1_source_credit_footer.lua +++ b/tests/engine/gen1_source_credit_footer.lua @@ -2,6 +2,8 @@ package.path='./?.lua;./?/init.lua;'..package.path local T=require('tests.modkit') local run=T.sdk.loadNone({data=T.fixtures.fresh(),fs=T.sdk.memfs({})}) +love.system=love.system or {} +love.system.getOS=function()return 'Unknown'end local Importer=require('src.import.RomImporter') local g=love.graphics local dimensions,printf,setFont=g.getDimensions,g.printf,g.setFont diff --git a/tests/engine/gen2_bg_sdk.lua b/tests/engine/gen2_bg_sdk.lua new file mode 100644 index 000000000..845f97eee --- /dev/null +++ b/tests/engine/gen2_bg_sdk.lua @@ -0,0 +1,35 @@ +-- Native BG API first import from a real SDK entry, with no ROM or renderer. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local before=Version.get() +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + local run=T.sdk.loadMods({'mods/bg_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/bg_probe/manifest.json']=[[{"id":"bg_probe","name":"BG probe","version":"1.0.0","api":2,"entry":"main.lua"}]], + ['mods/bg_probe/main.lua']=[[ + local mod=... + local Bg=require('src.battle.gen2.BgEffects') + local pool=Bg.new({}, {battleTurn=0}) + mod.exports.pool=pool + mod.exports.enter=function() + pool:queue('BATTLE_BG_EFFECT_ENTER_MON',0,1,0) + pool:playFrame() + return pool.picSize.player + end + ]], + })}) + T.eq(#run.errors,0,'actual SDK entry can import the real engine BG module') + local api=run.loader.exports.bg_probe + T.check(api and api.pool~=nil,'native BG owner reaches the unmodified SDK export path') + if api then + T.eq(api.enter(),2,'SDK consumer runs the actual native entry state machine') + local row=api.pool:queue(0x25,0,1,0) + T.eq(row.func,edition=='crystal'and'BATTLE_BG_EFFECT_BODY_SLAM'or'BATTLE_BG_EFFECT_WOBBLE_MON', + 'actual native host edition resolves raw BG ids inside a mod entry') + end + require('src.mods.Loader').teardown();run.release() +end +Version.set(before) +T.finish('GSC background state machines through actual SDK module lookup') From 841951ab26d43e2cc6043989cecfb03c1a0b4c3c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:39:59 +0100 Subject: [PATCH 069/187] Retain the actual native draw error in footer test diagnostics --- tests/engine/gen1_source_credit_footer.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua index 85ded0eda..ec439adfb 100644 --- a/tests/engine/gen1_source_credit_footer.lua +++ b/tests/engine/gen1_source_credit_footer.lua @@ -22,7 +22,8 @@ for _,size in ipairs({{960,720},{480,360},{1280,960}})do width,height=size[1],size[2] for _,tab in ipairs({'red','gold','crystal','emerald'})do ui.tab=tab;printed={} - T.check(pcall(ui.draw,ui),'actual native launcher still draws with wrapped attribution') + local ok,why=pcall(ui.draw,ui) + T.check(ok,'actual native launcher still draws with wrapped attribution: '..tostring(why)) local line=printed[#printed] T.check(line~=nil,'native launcher renders the original project credit') if line then From 8e825446d0817504b3c1e59f3168213ee22f313c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:43:28 +0100 Subject: [PATCH 070/187] Exercise native attribution layout with measurable headless graphics devices --- tests/engine/gen1_source_credit_footer.lua | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua index ec439adfb..96f0195bf 100644 --- a/tests/engine/gen1_source_credit_footer.lua +++ b/tests/engine/gen1_source_credit_footer.lua @@ -7,8 +7,41 @@ love.system.getOS=function()return 'Unknown'end local Importer=require('src.import.RomImporter') local g=love.graphics local dimensions,printf,setFont=g.getDimensions,g.printf,g.setFont +local widthFn,heightFn,safeArea=g.getWidth,g.getHeight,love.window.getSafeArea +local mesh,shader,image,newFont=g.newMesh,g.newShader,g.newImage,g.newFont +local oldImage=love.image +local function noop()end +g.newMesh=function()return {release=noop}end +g.newShader=function()return {send=noop,release=noop}end +love.image={newImageData=function(w,h)return {w=w,h=h,setPixel=noop}end} +g.newImage=function(source) + local obj=type(source)=='table'and {w=source.w,h=source.h}or image(source) + obj.setWrap,obj.setFilter=noop,noop + obj.getDimensions=function(self)return self.w,self.h end + return obj +end +g.newFont=function(a,b) + local font=newFont(type(a)=='number'and a or b) + function font:getWrap(text,limit) + local lines={} + for line in (tostring(text)..'\n'):gmatch('(.-)\n')do + local current='' + for word in line:gmatch('%S+')do + local nextLine=current==''and word or current..' '..word + if current~=''and self:getWidth(nextLine)>limit then lines[#lines+1]=current;current=word + else current=nextLine end + end + lines[#lines+1]=current + end + return limit,lines + end + return font +end local width,height=960,720 g.getDimensions=function()return width,height end +g.getWidth=function()return width end +g.getHeight=function()return height end +love.window.getSafeArea=function()return 0,0,width,height end local printed,currentFont={},nil g.setFont=function(font)currentFont=font;return setFont(font)end g.printf=function(text,x,y,w,...) @@ -38,5 +71,8 @@ for _,size in ipairs({{960,720},{480,360},{1280,960}})do end end g.getDimensions,g.printf,g.setFont=dimensions,printf,setFont +g.getWidth,g.getHeight,love.window.getSafeArea=widthFn,heightFn,safeArea +g.newMesh,g.newShader,g.newImage,g.newFont=mesh,shader,image,newFont +love.image=oldImage run.release() T.finish('Gen1 source attribution through native launcher footer') From 6b263705d9ab342a9f708cee40ace78b5ef16cf7 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:44:11 +0100 Subject: [PATCH 071/187] Supply the native launcher stencil device boundary in layout test --- tests/engine/gen1_source_credit_footer.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua index 96f0195bf..96084ff55 100644 --- a/tests/engine/gen1_source_credit_footer.lua +++ b/tests/engine/gen1_source_credit_footer.lua @@ -11,6 +11,9 @@ local widthFn,heightFn,safeArea=g.getWidth,g.getHeight,love.window.getSafeArea local mesh,shader,image,newFont=g.newMesh,g.newShader,g.newImage,g.newFont local oldImage=love.image local function noop()end +local stencil,stencilTest=g.stencil,g.setStencilTest +g.stencil=function(fn)fn()end +g.setStencilTest=noop g.newMesh=function()return {release=noop}end g.newShader=function()return {send=noop,release=noop}end love.image={newImageData=function(w,h)return {w=w,h=h,setPixel=noop}end} @@ -74,5 +77,6 @@ g.getDimensions,g.printf,g.setFont=dimensions,printf,setFont g.getWidth,g.getHeight,love.window.getSafeArea=widthFn,heightFn,safeArea g.newMesh,g.newShader,g.newImage,g.newFont=mesh,shader,image,newFont love.image=oldImage +g.stencil,g.setStencilTest=stencil,stencilTest run.release() T.finish('Gen1 source attribution through native launcher footer') From 08579a58e21adc3be3f504714a2aa8f54eee04f6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:44:55 +0100 Subject: [PATCH 072/187] Complete mesh update support in headless launcher graphics fixture --- tests/engine/gen1_source_credit_footer.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua index 96084ff55..4d1cddbed 100644 --- a/tests/engine/gen1_source_credit_footer.lua +++ b/tests/engine/gen1_source_credit_footer.lua @@ -14,7 +14,7 @@ local function noop()end local stencil,stencilTest=g.stencil,g.setStencilTest g.stencil=function(fn)fn()end g.setStencilTest=noop -g.newMesh=function()return {release=noop}end +g.newMesh=function()return {release=noop,setVertices=noop}end g.newShader=function()return {send=noop,release=noop}end love.image={newImageData=function(w,h)return {w=w,h=h,setPixel=noop}end} g.newImage=function(source) From 80fde07f4b0c30fa64f98ca23c36c4de28589a51 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:45:52 +0100 Subject: [PATCH 073/187] Support remaining sidebar shape primitives in the headless draw fixture --- tests/engine/gen1_source_credit_footer.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua index 4d1cddbed..24bf4998e 100644 --- a/tests/engine/gen1_source_credit_footer.lua +++ b/tests/engine/gen1_source_credit_footer.lua @@ -11,6 +11,8 @@ local widthFn,heightFn,safeArea=g.getWidth,g.getHeight,love.window.getSafeArea local mesh,shader,image,newFont=g.newMesh,g.newShader,g.newImage,g.newFont local oldImage=love.image local function noop()end +local ellipse,arc,polygon=g.ellipse,g.arc,g.polygon +g.ellipse,g.arc,g.polygon=noop,noop,noop local stencil,stencilTest=g.stencil,g.setStencilTest g.stencil=function(fn)fn()end g.setStencilTest=noop @@ -78,5 +80,6 @@ g.getWidth,g.getHeight,love.window.getSafeArea=widthFn,heightFn,safeArea g.newMesh,g.newShader,g.newImage,g.newFont=mesh,shader,image,newFont love.image=oldImage g.stencil,g.setStencilTest=stencil,stencilTest +g.ellipse,g.arc,g.polygon=ellipse,arc,polygon run.release() T.finish('Gen1 source attribution through native launcher footer') From c0ee510adab9d06bec5775c207342c65ded64bb1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:46:50 +0100 Subject: [PATCH 074/187] Check footer controls independently from the existing pinned header donation chip --- tests/engine/gen1_source_credit_footer.lua | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua index 24bf4998e..feede9939 100644 --- a/tests/engine/gen1_source_credit_footer.lua +++ b/tests/engine/gen1_source_credit_footer.lua @@ -10,6 +10,8 @@ local dimensions,printf,setFont=g.getDimensions,g.printf,g.setFont local widthFn,heightFn,safeArea=g.getWidth,g.getHeight,love.window.getSafeArea local mesh,shader,image,newFont=g.newMesh,g.newShader,g.newImage,g.newFont local oldImage=love.image +local mouseDown=love.mouse.isDown +love.mouse.isDown=function()return false end local function noop()end local ellipse,arc,polygon=g.ellipse,g.arc,g.polygon g.ellipse,g.arc,g.polygon=noop,noop,noop @@ -21,7 +23,7 @@ g.newShader=function()return {send=noop,release=noop}end love.image={newImageData=function(w,h)return {w=w,h=h,setPixel=noop}end} g.newImage=function(source) local obj=type(source)=='table'and {w=source.w,h=source.h}or image(source) - obj.setWrap,obj.setFilter=noop,noop + obj.setWrap,obj.setFilter,obj.setMipmapFilter=noop,noop,noop obj.getDimensions=function(self)return self.w,self.h end return obj end @@ -70,7 +72,9 @@ for _,size in ipairs({{960,720},{480,360},{1280,960}})do local _,lines=line.font:getWrap(line.text,line.width) local bottom=line.y+math.max(1,#lines)*line.font:getHeight() for _,button in ipairs(ui.donateButtons or {})do - T.check(button.y>=bottom,'native footer reserves the full wrapped credit before donation controls') + if not button.pinned then + T.check(button.y>=bottom,'native footer reserves the full wrapped credit before its donation controls') + end end end end @@ -79,6 +83,7 @@ g.getDimensions,g.printf,g.setFont=dimensions,printf,setFont g.getWidth,g.getHeight,love.window.getSafeArea=widthFn,heightFn,safeArea g.newMesh,g.newShader,g.newImage,g.newFont=mesh,shader,image,newFont love.image=oldImage +love.mouse.isDown=mouseDown g.stencil,g.setStencilTest=stencil,stencilTest g.ellipse,g.arc,g.polygon=ellipse,arc,polygon run.release() From e7188e00f14b43e17a12ccd8be49999bbacc9278 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:49:48 +0100 Subject: [PATCH 075/187] Connect cartridge BG commands to the existing native animation update clock --- src/battle/Gen2AnimPlayer.lua | 35 +++++++++++- tests/engine/gen2_native_bg_clock.lua | 78 +++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 tests/engine/gen2_native_bg_clock.lua diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index 2a5075488..2fdb0f1df 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -35,6 +35,9 @@ -- y' = fixY == $FF and 40 + y or fixY - y -- xOffset' = -xOffset +local BgEffects = require('src.battle.gen2.BgEffects') +local BgIds = require('src.battle.Gen2BgEffectIds') + local Gen2AnimPlayer = {} Gen2AnimPlayer.__index = Gen2AnimPlayer @@ -141,6 +144,7 @@ function Gen2AnimPlayer.new(data) warned = {}, objects = {}, effects = {}, + bg = BgEffects.new(data and data.constants, {battleTurn=0}), -- WideBattle reads these two off the Gen 1 player; keep them defined steps = {}, stepIndex = 1, @@ -259,6 +263,11 @@ function Gen2AnimPlayer:start(moveId, attackerIsPlayer, opts) attackerIsPlayer = not attackerIsPlayer end self.enemySide = not attackerIsPlayer + -- Existing VM updates own this pool's time; a restart retires the old block. + self.bg = BgEffects.new(self.data and self.data.constants, { + battleTurn = self.enemySide and 1 or 0, + flying = opts and opts.flying or {}, + }) self.opts = opts self.moveId = moveId self.loops = {} @@ -388,6 +397,19 @@ local BG_EFFECTS_END = { } Gen2AnimPlayer.BG_EFFECTS_END = BG_EFFECTS_END +-- The legacy visual adapter is keyed by the Gold/Silver table. Cartridge +-- commands must resolve by NAME first: Crystal inserts BODY_SLAM at $25. +-- Keep this adapter until the native register/scanline draw owner is wired; +-- the actual pool below is authoritative for public BG state. +local legacyBgIds = {} +for index,name in ipairs(BgIds.forVersion('gold')) do legacyBgIds[name]=index-1 end +local function legacyBgId(self,id) + local name=type(id)=='number'and self.bg.order[id+1]or id + if name=='BATTLE_BG_EFFECT_BODY_SLAM'then return 0x24 end + return legacyBgIds[name] +end + + -- BGEffect_CheckBattleTurn: BG_EFFECT_STRUCT_BATTLE_TURN is 1 for the move's -- user and 0 for its target. Anything else (the flash routines reuse the -- field as a repeat count) only ever drives screen-wide effects, which @@ -681,17 +703,23 @@ function Gen2AnimPlayer:step(inst) -- side the queue entry was made with self.bgSides = self.bgSides or {} self.bgSides[id] = side - local se = BG_EFFECTS[id] + self.bg:queue(id, inst.args[2], inst.args[3], inst.args[4]) + local se = BG_EFFECTS[legacyBgId(self,id)] if se then self.effects[#self.effects + 1] = { effect = se, side = side, gen2 = true } end elseif op == "incbgeffect" then local id = inst.args[1] - local se = BG_EFFECTS_END[id] + self.bg:incEffect(id) + local se = BG_EFFECTS_END[legacyBgId(self,id)] if se then local side = self.bgSides and self.bgSides[id] or "target" self.effects[#self.effects + 1] = { effect = se, side = side, gen2 = true } end + elseif op == 'bgp' or op == 'obp0' or op == 'obp1' then + self.bg[op] = (inst.args[1] or 0) % 256 + elseif op == 'resetobp0' then + self.bg.obp0 = self.opts and self.opts.sgb and 0xf0 or 0xe0 elseif op == "checkpokeball" then -- BattleAnimCmd_CheckPokeball calls GetPokeBallWobble and stores its `c` -- into wBattleAnimVar, which the toss script then compares against 1 and @@ -826,6 +854,7 @@ function Gen2AnimPlayer:update() end if self.wait > 0 then self.wait = self.wait - 1 + self.bg:playFrame() self:tickObjects() return end @@ -836,6 +865,7 @@ function Gen2AnimPlayer:update() if self.ranFrames > 180 then self.done = true self.tail = TAIL_FRAMES + self.bg:playFrame() self:tickObjects() return end @@ -850,6 +880,7 @@ function Gen2AnimPlayer:update() if self.done or self.wait > 0 then break end end if self.wait > 0 then self.wait = self.wait - 1 end + self.bg:playFrame() self:tickObjects() end diff --git a/tests/engine/gen2_native_bg_clock.lua b/tests/engine/gen2_native_bg_clock.lua new file mode 100644 index 000000000..c8cb51d07 --- /dev/null +++ b/tests/engine/gen2_native_bg_clock.lua @@ -0,0 +1,78 @@ +-- Exercise real native VM commands and its actual BG register owner. +-- Official main has no Gen2AnimView observation; no substitute runner is installed. +-- Expected picture and LCD traces come from the pinned GSC BG implementation. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Player=require('src.battle.Gen2AnimPlayer') +local Version=require('src.core.GameVersion') +local previous=Version.get() +local function data(code,constants) + return {constants=constants,battle_anims={gen2=true,moveAnims={PROBE=1}, + scripts={[1]={entry=1,code=code}},objects={},framesets={},oam={},gfx={}}} +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + for _,playerTurn in ipairs({true,false})do + local p=Player.new(data({ + {op='bgeffect',args={0x0b,0,1,0}}, + {op='delay',frames=13},{op='ret'}, + })) + T.check(p:start('PROBE',playerTurn),'native VM starts the real test program') + local r=p + T.eq(r.bg,p.bg,'observation exposes the actual register owner') + local ticks=0;local playFrame=p.bg.playFrame + p.bg.playFrame=function(self,...)ticks=ticks+1;return playFrame(self,...)end + local side=playerTurn and 'player'or'enemy' + for frame=1,13 do + p:update() + local stage=math.floor((frame-1)/4) + local expected=stage<3 and((playerTurn and 2 or 5)-stage)or nil + T.eq(r.bg.picSize[side],expected,'native command/wait path follows four-frame picture stages') + T.eq(ticks,frame,'one existing VM update executes exactly one BG pass') + for _=1,20 do local bg,frames=r.bg,r.frames end + T.eq(ticks,frame,'repeated consumer observations never run the effect') + end + p:update();T.eq(ticks,14,'the final command frame executes one BG pass') + for _=1,20 do p:update()end + T.eq(ticks,14,'legacy object-tail drain cannot keep advancing a stopped BG program') + local old=r.bg + p:start('PROBE',not playerTurn) + T.eq(old.picSize[side],nil,'previous register block keeps its completed picture trace') + T.check(p.bg~=old,'restart owns a fresh block rather than mutating retired state') + T.eq(p.bg.env.battleTurn,playerTurn and 1 or 0,'restart binds the current attacker side') + T.eq(p.bg.picSize.player,nil,'restart clears player picture stages') + T.eq(p.bg.picSize.enemy,nil,'restart clears enemy picture stages') + p:release() + end + local p=Player.new(data({{op='bgeffect',args={0x25,0,1,0}}, + {op='delay',frames=4},{op='ret'}})) + p:start('PROBE',true);p:update() + local entry=p.bg.effects[1] + T.eq(entry.func,edition=='crystal'and'BATTLE_BG_EFFECT_BODY_SLAM'or'BATTLE_BG_EFFECT_WOBBLE_MON', + 'raw command resolves through the actual cartridge table') + local effect=p:pollEffects()[1] + T.eq(effect and effect.effect,edition=='crystal'and'SE_MOVE_MON_HORIZONTALLY'or'SE_SHAKE_BACK_AND_FORTH', + 'existing visual adapter also resolves Crystal insertion by effect name') + p:update();p:update() + if edition=='crystal'then T.eq(p.bg.lyBackup[0x30],0xfe,'real VM drives the signed BODY_SLAM LCD trace')end + p:release() + p=Player.new(data({{op='bgeffect',args={0x0f,0,0,0}}, + {op='delay',frames=1},{op='incbgeffect',args={0x0f}}, + {op='bgp',args={0x1ff}},{op='obp0',args={0x18c}}, + {op='obp1',args={0x124}},{op='delay',frames=1},{op='ret'}})) + p:start('PROBE',true);p:update() + local active=p.bg.effects[1] + T.eq(active.jt,1,'native Teleport initializes its running state') + p:update() + T.eq(active.func,nil,'increment advances Teleport into its native terminal state') + T.eq(p.bg.lcdc,nil,'native terminal state retires its LCD window') + T.eq(p.bg.obp0,0x8c,'native OBJ0 palette command preserves byte semantics') + T.eq(p.bg.obp1,0x24,'native OBJ1 palette command preserves byte semantics') + T.eq(p.bg.bgp,0xff,'BG palette write survives a non-palette terminal effect') + p:release() + p=Player.new(data({{op='bgp',args={0x1fc}},{op='delay',frames=1},{op='ret'}})) + p:start('PROBE',true);p:update();T.eq(p.bg.bgp,0xfc,'native BG palette write reaches the register block') + p:release() +end +Version.set(previous) +T.finish('native GSC BG commands, one-clock stepping and retired observations') From 1ba98f0f7af771ba76182b526b87d675ac09e77d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:51:37 +0100 Subject: [PATCH 076/187] Record native clock integration and outstanding BG rendering work --- compat/gen1-gsc/provenance.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/compat/gen1-gsc/provenance.json b/compat/gen1-gsc/provenance.json index d9ee9f74a..2ab447685 100644 --- a/compat/gen1-gsc/provenance.json +++ b/compat/gen1-gsc/provenance.json @@ -13,8 +13,9 @@ "scope": "BgEffects implementation, arithmetic subset and BG-effect identifier lists only. No launcher, game assets, ROM bytes or player state copied.", "modifications": [ "BgEffects arithmetic dependency uses Gen2AnimMath instead of the full reference object pool.", - "A missing jump-table list resolves through edition-specific Gen2BgEffectIds for older native caches." + "A missing jump-table list resolves through edition-specific Gen2BgEffectIds for older native caches.", + "Existing Gen2AnimPlayer dispatches BG/palette commands and steps the pool from its current VM update; its legacy visual adapter resolves edition-specific IDs by names." ], "licence": "Gen1Recomp GPLv3 with its Additional Terms, retained in LICENSE.MD. Existing authors retain their rights; Roxas2712 integration changes in these derived files are offered on those applicable terms.", - "qualification": "Standalone native BG API and CPU state-machine traces only. Native animation VM / drawing integration and actual client GPU acceptance remain required before merge." + "qualification": "CPU native pool, actual SDK entry and native VM clock integration tested. Scanline drawing and battler-picture OAM handoff are not integrated; no GPU/client/release acceptance." } From 58838e6b1305111cb45d2178ee11d23ae1629cb6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:16:43 +0100 Subject: [PATCH 077/187] Present genuine GSC BG scanlines and picture states through native classic and wide battle owners --- compat/gen1-gsc/provenance.json | 14 +- src/battle/BattleState.lua | 1 + src/battle/Gen2AnimPlayer.lua | 131 +-------- src/battle/Gen2AnimView.lua | 6 +- src/battle/Gen2BattleScene.lua | 6 +- src/battle/Gen2BgPaint.lua | 27 ++ src/battle/Gen2BgPictures.lua | 121 +++++++++ src/battle/Gen2BgView.lua | 364 ++++++++++++++++++++++++++ src/battle/WideBattle.lua | 29 +- tests/engine/gen2_bg_presenter.lua | 159 +++++++++++ tests/engine/gen2_native_bg_clock.lua | 13 +- tests/love_stub.lua | 10 +- 12 files changed, 739 insertions(+), 142 deletions(-) create mode 100644 src/battle/Gen2BgPaint.lua create mode 100644 src/battle/Gen2BgPictures.lua create mode 100644 src/battle/Gen2BgView.lua create mode 100644 tests/engine/gen2_bg_presenter.lua diff --git a/compat/gen1-gsc/provenance.json b/compat/gen1-gsc/provenance.json index 2ab447685..33c3e99c5 100644 --- a/compat/gen1-gsc/provenance.json +++ b/compat/gen1-gsc/provenance.json @@ -8,14 +8,20 @@ "tools/rom_manifest_gold.json": "ebfd906ef715c634c12ca168dbf711161385720f02c6e0a63d9481a815550a1e", "tools/rom_manifest_silver.json": "7575919b843c2addf096ed3db7b37dedb9f4b9c8129d32eb0d9ed6c9e5333f52", "tools/rom_manifest_crystal.json": "be373e361090d79f413ad4d1e57d28c5ac1aa00650d4fa361289a78ef8397b31", - "LICENSE.MD": "b369ce45656b3d87453541816fac6982b13e09ea0734ab9cfe61cf3faf5e95ed" + "LICENSE.MD": "b369ce45656b3d87453541816fac6982b13e09ea0734ab9cfe61cf3faf5e95ed", + "src/ui/gen2/BattleAnimView.lua": "c67613148f14987bdc76d01ace2cd90e89f7d742e4c4a0e95889707f5a9d0e61", + "src/ui/gen2/BattleState.lua": "6789c83e5124701d7ebfb1bdc74389102fa85c02f5ecdb812baf0905bded723c", + "src/ui/gen2/Chrome.lua": "a81231beac5334eae58df0936fed1f4fec90b3d986e8caf8b31b5e522180dc52" }, - "scope": "BgEffects implementation, arithmetic subset and BG-effect identifier lists only. No launcher, game assets, ROM bytes or player state copied.", + "scope": "BgEffects, BG presenter, arithmetic subset and BG-effect identifier lists. No launcher, game assets, ROM bytes or player state copied.", "modifications": [ "BgEffects arithmetic dependency uses Gen2AnimMath instead of the full reference object pool.", "A missing jump-table list resolves through edition-specific Gen2BgEffectIds for older native caches.", - "Existing Gen2AnimPlayer dispatches BG/palette commands and steps the pool from its current VM update; its legacy visual adapter resolves edition-specific IDs by names." + "Existing Gen2AnimPlayer dispatches BG/palette commands and steps the pool from its current VM update. Native scene and picture owners consume these registers directly, replacing the legacy SE approximations.", + "BG presenter uses native palette/chrome/canvas owners, supports the real wide surface, preserves transparency and restores graphics state on supplied-panel errors.", + "Battler resize, hide, palette and lifted-row drawing remain attached to native image owners. Object function motion and complete send-out event sequencing remain separate work.", + "The BG-only paper and clipping subset uses the native indexed palette owner, without requiring the full menu Chrome module on older engines." ], "licence": "Gen1Recomp GPLv3 with its Additional Terms, retained in LICENSE.MD. Existing authors retain their rights; Roxas2712 integration changes in these derived files are offered on those applicable terms.", - "qualification": "CPU native pool, actual SDK entry and native VM clock integration tested. Scanline drawing and battler-picture OAM handoff are not integrated; no GPU/client/release acceptance." + "qualification": "CPU pool, SDK entry, native VM and classic/wide BG rendering contracts tested. One unchanged-VASC Crystal Metal battle reached a native faint, win, EXP award and world return with real window captures on ce12c485. Full object motion, send-out sequencing, all effects GPU frames, other platforms and release qualification remain required." } diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index acbd5556b..a4eb11706 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -13030,5 +13030,6 @@ end require('src.battle.Gen2MovePresentation').install(BattleState) require('src.battle.Gen2BattleScene').install(BattleState) +require('src.battle.Gen2BgPictures').install(BattleState) return BattleState diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index 1fda64cf2..06d3d7ca5 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -36,7 +36,6 @@ -- xOffset' = -xOffset local BgEffects = require('src.battle.gen2.BgEffects') -local BgIds = require('src.battle.Gen2BgEffectIds') local Gen2AnimPlayer = {} Gen2AnimPlayer.__index = Gen2AnimPlayer @@ -146,8 +145,8 @@ function Gen2AnimPlayer.new(data) objects = {}, effects = {}, frames = 0, - tail = 0, bg = BgEffects.new(data and data.constants, {battleTurn=0}), + tail = 0, -- WideBattle reads these two off the Gen 1 player; keep them defined steps = {}, stepIndex = 1, @@ -273,7 +272,8 @@ function Gen2AnimPlayer:start(moveId, attackerIsPlayer, opts) attackerIsPlayer = not attackerIsPlayer end self.enemySide = not attackerIsPlayer - -- Existing VM updates own this pool's time; a restart retires the old block. + -- The real command VM owns this pool's clock. Each restart gets a fresh + -- register block, so retired animation observations keep their own state. self.bg = BgEffects.new(self.data and self.data.constants, { battleTurn = self.enemySide and 1 or 0, flying = opts and opts.flying or {}, @@ -329,68 +329,9 @@ function Gen2AnimPlayer:isDone() return self.done and self.tail <= 0 end --- `anim_bgeffect` queues one of BattleBGEffects' asm routines --- (BATTLE_BG_EFFECT_*, constants/battle_anim_constants.asm). Those are the --- screen shakes, palette flashes and mon-pic moves -- everything an --- animation does outside its own objects -- so without them a Gen 2 battle --- showed the sprites and none of the impact. BattleState already implements --- Gen 1's SE_* vocabulary for exactly these effects, so each id routes onto --- its closest counterpart rather than a second fx layer. --- --- The side each one acts on is NOT baked into the id: every routine resolves --- it through BGEffect_CheckBattleTurn, which is `hBattleTurn XOR --- BG_EFFECT_STRUCT_BATTLE_TURN` -- the third `anim_bgeffect` argument, 1 for --- the user and 0 for the target. So the names below are all the unflipped --- SE_* row and `side` on the queued event picks the battler. -local BG_EFFECTS = { - [0x01] = "SE_DARK_SCREEN_FLASH", -- FLASH_INVERTED - [0x02] = "SE_DARK_SCREEN_FLASH", -- FLASH_WHITE - [0x03] = "SE_LIGHT_SCREEN_PALETTE",-- WHITE_HUES - [0x04] = "SE_DARK_SCREEN_PALETTE", -- BLACK_HUES - [0x05] = "SE_FLASH_SCREEN_LONG", -- ALTERNATE_HUES - [0x08] = "SE_FLASH_SCREEN_LONG", -- CYCLE_BGPALS_INVERTED - [0x09] = "SE_HIDE_MON_PIC", -- HIDE_MON - [0x0A] = "SE_SHOW_MON_PIC", -- SHOW_MON - [0x0B] = "SE_SHOW_MON_PIC", -- ENTER_MON - [0x0C] = "SE_HIDE_MON_PIC", -- RETURN_MON - [0x0F] = "SE_SLIDE_MON_UP", -- TELEPORT - [0x13] = "SE_BLINK_MON", -- DOUBLE_TEAM - [0x14] = "SE_HIDE_MON_PIC", -- ACID_ARMOR - [0x15] = "SE_FLASH_SCREEN_LONG", -- RAPID_FLASH - [0x16] = "SE_LIGHT_SCREEN_PALETTE",-- FADE_MON_TO_LIGHT - [0x17] = "SE_DARKEN_MON_PALETTE", -- FADE_MON_TO_BLACK - [0x18] = "SE_LIGHT_SCREEN_PALETTE",-- FADE_MON_TO_LIGHT_REPEATING - [0x19] = "SE_DARKEN_MON_PALETTE", -- FADE_MON_TO_BLACK_REPEATING - [0x1A] = "SE_FLASH_SCREEN_LONG", -- CYCLE_MON_LIGHT_DARK_REPEATING - [0x1B] = "SE_BLINK_MON", -- FLASH_MON_REPEATING - [0x1C] = "SE_DARKEN_MON_PALETTE", -- FADE_MONS_TO_BLACK_REPEATING - [0x1D] = "SE_LIGHT_SCREEN_PALETTE",-- FADE_MON_TO_WHITE_WAIT_FADE_BACK - [0x1E] = "SE_RESET_SCREEN_PALETTE",-- FADE_MON_FROM_WHITE - [0x1F] = "SE_SHAKE_SCREEN", -- SHAKE_SCREEN_X - [0x20] = "SE_ROCK_SLIDE_SHAKE", -- SHAKE_SCREEN_Y - [0x21] = "SE_SQUISH_MON_PIC", -- WITHDRAW - [0x22] = "SE_BOUNCE_UP_AND_DOWN", -- BOUNCE_DOWN - [0x23] = "SE_SLIDE_MON_DOWN_AND_HIDE", -- DIG - [0x24] = "SE_MOVE_MON_HORIZONTALLY", -- TACKLE - [0x25] = "SE_SHAKE_BACK_AND_FORTH",-- WOBBLE_MON - [0x26] = "SE_HIDE_MON_PIC", -- REMOVE_MON - [0x27] = "SE_WAVY_SCREEN", -- WAVE_DEFORM_MON - [0x28] = "SE_WAVY_SCREEN", -- PSYCHIC - [0x2B] = "SE_SHAKE_BACK_AND_FORTH",-- FLAIL - [0x2D] = "SE_SHAKE_SCREEN", -- ROLLOUT - [0x2E] = "SE_SHAKE_SCREEN", -- VITAL_THROW - [0x32] = "SE_SHAKE_BACK_AND_FORTH",-- VIBRATE_MON - [0x33] = "SE_SHAKE_BACK_AND_FORTH",-- WOBBLE_PLAYER - [0x34] = "SE_WAVY_SCREEN", -- WOBBLE_SCREEN -} - --- `anim_incbgeffect id` bumps that effect's BG_EFFECT_STRUCT_JT_INDEX, and --- for every routine a script ends this way the state it lands on is the one --- that puts back what the effect changed and calls EndBattleBGEffect: --- BGEffect_RapidCyclePals' .two_dmg reloads rBGP with $e4, the deformations' --- last state is BattleAnim_ResetLCDStatCustom. The port ran none of it, so --- Growl's FADE_MON_TO_BLACK_REPEATING left its darkened palette up for the --- rest of the battle and Tail Whip's WOBBLE_MON never put the mon back. +-- The historical SE-reset lookup remains available to existing consumers. +-- The native VM no longer emits these substitutes: Gen2BgView and native +-- picture drawing consume the genuine BG owner directly. local BG_EFFECTS_END = { [0x03] = "SE_RESET_SCREEN_PALETTE", -- WHITE_HUES [0x04] = "SE_RESET_SCREEN_PALETTE", -- BLACK_HUES @@ -417,45 +358,10 @@ local BG_EFFECTS_END = { } Gen2AnimPlayer.BG_EFFECTS_END = BG_EFFECTS_END --- The legacy visual adapter is keyed by the Gold/Silver table. Cartridge --- commands must resolve by NAME first: Crystal inserts BODY_SLAM at $25. --- Keep this adapter until the native register/scanline draw owner is wired; --- the actual pool below is authoritative for public BG state. -local legacyBgIds = {} -for index,name in ipairs(BgIds.forVersion('gold')) do legacyBgIds[name]=index-1 end -local function legacyBgId(self,id) - local name=type(id)=='number'and self.bg.order[id+1]or id - if name=='BATTLE_BG_EFFECT_BODY_SLAM'then return 0x24 end - return legacyBgIds[name] -end - - --- BGEffect_CheckBattleTurn: BG_EFFECT_STRUCT_BATTLE_TURN is 1 for the move's --- user and 0 for its target. Anything else (the flash routines reuse the --- field as a repeat count) only ever drives screen-wide effects, which --- ignore the side. -local function bgSide(turn) - return turn == 1 and "user" or "target" -end - --- The effects that are deliberately dropped rather than unhandled, so the --- coverage test can tell the two apart. -Gen2AnimPlayer.BG_EFFECTS_DROPPED = { - -- object palette cycling; the port draws each object with its own - -- BattleObjectPals entry and has no OBP timer to cycle - [0x06] = true, [0x07] = true, - -- per-scanline BG raster (Surf/Whirlpool/the water backdrop) - [0x0D] = true, [0x0E] = true, [0x2F] = true, [0x30] = true, [0x31] = true, - -- BG tilemap dissolve - [0x10] = true, - -- BATTLEROBJ_1ROW/2ROW copy rows of the battler's own picture into the - -- object tiles. With no VRAM to copy into, the objects that depend on it - -- would draw the raw PLAYERHEAD/ENEMYFEET sheets instead -- the stray foot - -- that turned up in half the physical moves -- so spawn drops them. - [0x11] = true, [0x12] = true, - -- unused beta routines - [0x29] = true, [0x2A] = true, [0x2C] = true, -} +-- BG commands now execute their genuine register/picture state machines. +-- Battler-picture requests are drawn by the native lifted-row pass, while +-- the remaining approximate object runtime is replaced independently. +Gen2AnimPlayer.BG_EFFECTS_DROPPED = {} -- BATTLE_ANIM_GFX_PLAYERHEAD / _ENEMYFEET: these two sheets are placeholders -- that BATTLE_BG_EFFECT_BATTLEROBJ_*ROW overwrites with a slice of the @@ -718,24 +624,9 @@ function Gen2AnimPlayer:step(inst) end elseif op == "bgeffect" then local id = inst.args[1] - local side = bgSide(inst.args[3]) - -- anim_incbgeffect addresses a running effect by id alone, so keep the - -- side the queue entry was made with - self.bgSides = self.bgSides or {} - self.bgSides[id] = side self.bg:queue(id, inst.args[2], inst.args[3], inst.args[4]) - local se = BG_EFFECTS[legacyBgId(self,id)] - if se then - self.effects[#self.effects + 1] = { effect = se, side = side, gen2 = true } - end elseif op == "incbgeffect" then - local id = inst.args[1] - self.bg:incEffect(id) - local se = BG_EFFECTS_END[legacyBgId(self,id)] - if se then - local side = self.bgSides and self.bgSides[id] or "target" - self.effects[#self.effects + 1] = { effect = se, side = side, gen2 = true } - end + self.bg:incEffect(inst.args[1]) elseif op == 'bgp' or op == 'obp0' or op == 'obp1' then self.bg[op] = (inst.args[1] or 0) % 256 elseif op == 'resetobp0' then diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua index f2f8d3930..ff21a0533 100644 --- a/src/battle/Gen2AnimView.lua +++ b/src/battle/Gen2AnimView.lua @@ -27,6 +27,7 @@ function View:release() if image and image.release then image:release() end end self.images, self.quads = {}, {} + self:releaseBg() end function View:objPalette(name, battle) local index = slots[name] or tonumber(name) @@ -97,7 +98,7 @@ function View.runner(player) local function state() return {animId=player.presentationId or player.moveId,frames=player.frames or 0,var=player.vars and player.vars.var or 0, stopped=player:isDone(),keepSprites=player.keepSprites, - objects=objects(), oam=player:oam(selected)} + bg=player.bg, objects=objects(), oam=player:oam(selected)} end local runner = {} function runner:oam() return frozen and frozen.oam or player:oam(selected) end @@ -114,8 +115,11 @@ function View.runner(player) if key=='stopped'then return player:isDone()end if key=='keepSprites'then return player.keepSprites end if key=='objects'then return objects()end + if key=='bg'then return player.bg end end, __newindex=function() error('native Gen2 animation observation is read-only',2) end, }) end +-- BG methods use the same concrete view object as native OAM drawing. +for name,method in pairs(require('src.battle.Gen2BgView'))do View[name]=method end return View diff --git a/src/battle/Gen2BattleScene.lua b/src/battle/Gen2BattleScene.lua index 9f93267c5..53d1b1ece 100644 --- a/src/battle/Gen2BattleScene.lua +++ b/src/battle/Gen2BattleScene.lua @@ -111,8 +111,12 @@ function M.install(Screen) local g=love.graphics g.push('all') local ok,why=pcall(function() - if panelFn then panelFn()else self:drawPanel()end + local panel=panelFn or function()self:drawPanel()end + if self.anim and self.anim.bg and self.animView then + self.animView:present(self.anim,panel,self,width or 160) + else panel()end if self.blankForAskName then return end + self:drawLiftedRows(width) if objectsFn then objectsFn()else self:drawAnimLayer(self:colorMode())end local fx=self.fx if fx and fx.flash and fx.flash>0 and self.frame%4<2 then diff --git a/src/battle/Gen2BgPaint.lua b/src/battle/Gen2BgPaint.lua new file mode 100644 index 000000000..60190fe1a --- /dev/null +++ b/src/battle/Gen2BgPaint.lua @@ -0,0 +1,27 @@ +-- Gen1Recomp-derived BG paper/clip drawing. GPLv3 plus Additional Terms: +-- compat/gen1-gsc/LICENSE.MD and provenance.json. Uses native indexed colour. +local Palette=require('src.render.Gen2PaletteDraw') +local M={} +-- The GSC battle/menu paper palette from the pinned Gen1 Chrome owner. +local BOX={{255,255,255},{255,255,255},{255,255,255},{0,0,0}} +function M.paletteFill(x,y,w,h) + local g=love.graphics + local previous=g.getShader and g.getShader() + g.setColor(1,1,1,1) + if Palette.available()then Palette.use(BOX)end + g.rectangle('fill',x,y,w,h) + g.setShader(previous) + g.setColor(0,0,0,1) +end +function M.clipTo(x,y,w,h) + local g=love.graphics + if g.transformPoint then + local x1,y1=g.transformPoint(x,y) + local x2,y2=g.transformPoint(x+w,y+h) + x,y=math.floor(math.min(x1,x2)),math.floor(math.min(y1,y2)) + w,h=math.ceil(math.abs(x2-x1)),math.ceil(math.abs(y2-y1)) + end + if g.intersectScissor then g.intersectScissor(x,y,w,h) + else g.setScissor(x,y,w,h)end +end +return M diff --git a/src/battle/Gen2BgPictures.lua b/src/battle/Gen2BgPictures.lua new file mode 100644 index 000000000..fb2651ff6 --- /dev/null +++ b/src/battle/Gen2BgPictures.lua @@ -0,0 +1,121 @@ +-- Gen1Recomp-derived picture/register integration. GPLv3 plus Additional +-- Terms, compat/gen1-gsc/LICENSE.MD. See pinned provenance.json. +-- The existing native screen still owns images, battlers and all clocks. +local Version=require('src.core.GameVersion') +local Palette=require('src.render.Gen2PaletteDraw') +local Chrome=require('src.battle.Gen2BgPaint') +local M={} +local SIZES={[0]=6,[1]=4,[2]=2,[3]=7,[4]=5,[5]=3} + +-- Per-mon CopyPals first, whole-BG BGP second. Compose indices before the +-- existing image owner bakes them; never guess shades from finished RGB. +function M.paletteByte(monByte,bgByte) + local mon=Palette.bgpShades(monByte) + local bg=Palette.bgpShades(bgByte) + local out=0 + for i=0,3 do out=out+mon[bg[i+1]+1]*4^i end + return out +end +local function withPalette(screen,side,body) + if screen._gen2PicPaletteSide==side then return body()end + local state=screen:animPicState(side) + local previous=Palette.bgp + local owner=screen._gen2PicPaletteSide + screen._gen2PicPaletteSide=side + Palette.setBgp(M.paletteByte(state and state.shade,previous)) + local ok,why=pcall(body) + Palette.setBgp(previous) + screen._gen2PicPaletteSide=owner + if not ok then error(why,0)end +end +function M.install(Screen) + -- Older native screens need the same live observation; this reads their + -- actual battler and BG owners rather than constructing another model. + if not Screen.animPicState then + function Screen:animPicState(side) + local bg=self.anim and self.anim.bg + if not bg then return nil end + return {hidden=bg.hidden[side],size=bg.picSize[side], + shade=bg.monShade[side],slide=bg.slide[side]or 0, + lifted=bg.liftedRows[side]} + end + end + local draw=Screen.drawBattlerPic + function Screen:drawBattlerPic(b,x,y,scale) + if not Version.isGen2() or not(self.anim and self.anim.bg) then + return draw(self,b,x,y,scale) + end + local side=b.isPlayer and 'player'or'enemy' + local state=self:animPicState(side) + if state and state.hidden then return end + if self.liftedPass and not(state and state.lifted)then return end + if not self.liftedPass then x=x+(self.anim.bg.slide[side] or 0)end + local size=state and SIZES[state.size] + if size then + local image=self:battlerPic(b) + if image then + local factor=size/(b.isPlayer and 6 or 7) + x=x+math.floor(image:getWidth()*scale*(1-factor)/2) + y=y+math.floor(image:getHeight()*scale*(1-factor)) + scale=scale*factor + end + end + local function paint()withPalette(self,side,function()draw(self,b,x,y,scale)end)end + local lifted=state and state.lifted + if not lifted then return paint()end + local bandY=(b.isPlayer and 48 or 0)+lifted[1]*8 + local bandH=lifted[2]*8 + local function band(top,height) + local g=love.graphics + g.push('all') + local ok,why=pcall(function() + Chrome.clipTo(0,top,160,height) + paint() + end) + g.pop() + if not ok then error(why,0)end + end + if self.liftedPass then band(bandY,bandH) + else + if bandY>0 then band(0,bandY)end + local below=144-bandY-bandH + if below>0 then band(bandY+bandH,below)end + end + end + local drawPic=Screen.drawPic + if drawPic then + function Screen:drawPic(mon,back) + if not Version.isGen2() or not(self.anim and self.anim.bg)then + return drawPic(self,mon,back) + end + local side=self.player and self.player.mon==mon and 'player' + or self.enemy and self.enemy.mon==mon and 'enemy' + or (back and 'player'or'enemy') + return withPalette(self,side,function()drawPic(self,mon,back)end) + end + end + function Screen:drawLiftedRows(width) + local bg=self.anim and self.anim.bg + local rows=bg and bg.liftedRows + if not(rows and(rows.player or rows.enemy))then return end + local before=self.liftedPass + self.liftedPass=true + local previous=Palette.setBgp(bg.bgp) + local g=love.graphics + g.push('all') + local ok,why=pcall(function() + local slide=(self.introSlide or 0)*require('src.core.Timing').BATTLE_SLIDE_PX_PER_FRAME + if width and width>160 then + require('src.battle.WideBattle').drawPicRegions(self,slide) + else + if rows.enemy then self:drawPicsLayer(slide,0,0,'enemy',true)end + if rows.player then self:drawPicsLayer(slide,0,0,'player',true)end + end + end) + g.pop() + Palette.setBgp(previous) + self.liftedPass=before + if not ok then error(why,0)end + end +end +return M diff --git a/src/battle/Gen2BgView.lua b/src/battle/Gen2BgView.lua new file mode 100644 index 000000000..7d835ea37 --- /dev/null +++ b/src/battle/Gen2BgView.lua @@ -0,0 +1,364 @@ +-- Gen1Recomp-derived BG presenter, pinned at535a5ec0. GPLv3 plus +-- Additional Terms: compat/gen1-gsc/LICENSE.MD and provenance.json. +-- Native integration retains the real BG scanline pipeline and uses this +-- engine's palette and pixel-canvas owners. OAM is composited afterwards. +local PixelCanvas=require('src.render.PixelCanvas') +local Chrome=require('src.battle.Gen2BgPaint') +local GbcPalette=require('src.render.Gen2PaletteDraw') +local BgView={} +local SCREEN_W,SCREEN_H=160,144 +local function signed(value) + value=(value or 0)%256 + return value<0x80 and value or value-256 +end +local function needsCanvas(runner) + local bg = runner.bg + -- engine/battle_anims/bg_effects.asm:448-465: a lifted battler row stays + -- out of the BG until the next pic redraw, so those frames stay baked too. + local lifted = bg.liftedRows + if lifted and (lifted.player or lifted.enemy) then return true end + if bg.scx ~= 0 or bg.scy ~= 0 then return true end + if not bg.lcdc then return false end + if bg.lyEnd <= bg.lyStart then return false end + for row = bg.lyStart, math.min(bg.lyEnd, SCREEN_H) - 1 do + if (bg.lyBackup[row] or 0) ~= 0 then return true end + end + return false +end + +-- The battle background is BG colour 0 everywhere the two pic boxes and the +function BgView:fillBackground(palByte) + local SCREEN_W=self.bgWidth or 160 + if self.bgTransparent then return end + local previousBgp = GbcPalette.setBgp(palByte) + local ok, err = pcall(Chrome.paletteFill, 0, 0, SCREEN_W, SCREEN_H) + GbcPalette.setBgp(previousBgp) + if not ok then error(err, 0) end +end + +-- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn +-- CLIPPED to the 160-pixel screen rather than allowed to hang over the edge: +-- the cart's BG map wraps, so a scrolled scanline never spills past the LCD. +function BgView:blitRowAt(srcRow, destRow, dx) + local SCREEN_W=self.bgWidth or 160 + local canvas = self.canvas + if not self.blitQuad then + self.blitQuad = love.graphics.newQuad(0, 0, SCREEN_W, 1, SCREEN_W, SCREEN_H) + end + local srcX, width, destX = 0, SCREEN_W, dx + if dx > 0 then + width = SCREEN_W - dx + elseif dx < 0 then + srcX, width, destX = -dx, SCREEN_W + dx, 0 + end + if width <= 0 then return end + self.blitQuad:setViewport(srcX, srcRow, width, 1, SCREEN_W, SCREEN_H) + love.graphics.draw(canvas, self.blitQuad, destX, destRow) +end + +function BgView:blitRow(row, dx, dy) + self:blitRowAt(row, row + dy, dx) +end + +-- Draw the battle panel into the blit canvas, optionally with an rBGP byte +-- folded into every palette on the way in. +-- +-- The byte is set on GbcPalette rather than applied afterwards, which is what +-- makes it exact: the panel is still being DRAWN, so its palettes can take +-- CopyPals' permutation before they ever reach the shader. That is bit for bit +-- what DmgToCgbBGPals does, and it is why nothing here has to guess which +-- palette entry produced a finished pixel. +-- +-- pcall so a drawBg that throws cannot leave the byte standing on GbcPalette +-- for the rest of the frame. +function BgView:bake(drawBg, palByte) + local SCREEN_W=self.bgWidth or 160 + local G = love.graphics + if not self.canvas then + self.canvas = PixelCanvas.new(SCREEN_W, SCREEN_H) + self.canvas:setFilter("nearest", "nearest") + end + local previousBgp = GbcPalette.setBgp(palByte) + G.push('all') + local ok, err = pcall(function() + G.setCanvas(self.canvas) + G.setScissor() + G.clear(0,0,0,0) + G.origin() + drawBg() + end) + G.pop() + GbcPalette.setBgp(previousBgp) + if not ok then error(err,0) end +end + +-- The rBGP window's scanlines grouped by the byte they hold, identity first. +-- +-- `.SetLYOverridesBackup` writes one value on every SECOND row and leaves the +-- rest on whatever ClearLYOverrides put there, so a beta send-out frame holds +-- two bytes and FadeMonsToBlackRepeating's DMG path holds two bands of one +-- each. Grouping means the panel is re-baked twice a frame rather than 144 +-- times, and the grouping is by VALUE so a table that happens to repeat costs +-- nothing extra. +local function bgpBands(bg) + local base = bg.bgp or GbcPalette.BGP_IDENTITY + local order, bands = {}, {} + for row = 0, SCREEN_H - 1 do + -- home/lcd.asm:12 + local inWindow = row > bg.lyStart and row <= bg.lyEnd + -- Outside the window the register still reads whatever wBGP holds. + local byte = inWindow and (bg.lyBackup[row - 1] or base) or base + local band = bands[byte] + if not band then + band = { byte = byte, rows = {} } + bands[byte] = band + order[#order + 1] = band + end + band.rows[#band.rows + 1] = row + end + -- The base band first so the fillBackground below it happens before any blit + -- and the common band is the one drawn from the first bake. + table.sort(order, function(a, b) + if a.byte == b.byte then return false end + if a.byte == base then return true end + if b.byte == base then return false end + return a.rows[1] < b.rows[1] + end) + return order +end +BgView.bgpBands = bgpBands + +-- engine/battle_anims/bg_effects.asm:2638 +-- +-- One scanline's source row and x shift, or nil when the row shows nothing. +local function scanline(bg, scy, row) + local dx, src = 0, row + scy + -- home/lcd.asm:12 + local inWindow = bg.lcdc and bg.lcdc ~= "BGP" + and row > bg.lyStart and row <= bg.lyEnd + local byte = inWindow and (bg.lyBackup[row - 1] or 0) or 0 + if inWindow then + local value = signed(byte) + if bg.lcdc == "SCX" then dx = -value else src = src + value end + -- home/lcd.asm:3 + if byte ~= 0x90 and scy == 0 then + if src < 0 then src = 0 elseif src >= SCREEN_H then src = SCREEN_H - 1 end + end + end + if (not inWindow or byte ~= 0x90) and src >= 0 and src < SCREEN_H then + return src, dx + end + return nil +end + +function BgView.scanlines(bg) + local lines = {} + local scy = signed(bg.scy) + for row = 0, SCREEN_H - 1 do + local src, dx = scanline(bg, scy, row) + if src then + lines[#lines + 1] = { src = src, dest = row, dx = dx } + end + end + return lines +end + +-- Runs `drawBg` (the battle panel) and puts it on screen through the +-- animation's BG registers; skips the canvas when nothing needs one. +function BgView:present(runner, drawBg, battle) + local SCREEN_W=self.bgWidth or 160 + if not (love and love.graphics) then return end + local bg = runner.bg + -- engine/battle_anims/anim_commands.asm:1293 + local byte = bg.bgp ~= GbcPalette.BGP_IDENTITY and bg.bgp or nil + if not byte and not needsCanvas(runner) then + drawBg() + return + end + local G = love.graphics + + -- hLCDCPointer can also aim at rBGP, in which case each scanline gets its own + -- PALETTE rather than its own scroll (the beta send-out effects, and + -- FadeMonsToBlackRepeating on the DMG path). Nothing scrolls on that path, + -- so the whole of it is: bake the panel once per distinct byte and blit that + -- byte's rows out of it. + if bg.lcdc == "BGP" and GbcPalette.available() then + local baseX, baseY = -signed(bg.scx), -signed(bg.scy) + local bands = bgpBands(bg) + local filled = false + for _, band in ipairs(bands) do + self:bake(drawBg, band.byte) + G.setShader() + if not filled then + self:fillBackground(band.byte) + filled = true + end + G.setColor(1, 1, 1, 1) + for _, row in ipairs(band.rows) do + self:blitRow(row, baseX, baseY) + end + end + return + end + + self:bake(drawBg, byte) + G.setShader() + + -- A shifted scanline exposes the blank tile beside the pic boxes; without + -- this the exposed strip is the canvas's own transparency. + self:fillBackground(byte) + G.setColor(1, 1, 1, 1) + -- hSCX / hSCY move the whole background; the per-scanline overrides only + -- apply inside the effect's own window. + local baseX = -signed(bg.scx) + -- BgView.scanlines row by row, without a table per scanline. + local scy = signed(bg.scy) + for row = 0, SCREEN_H - 1 do + local src, dx = scanline(bg, scy, row) + if src then self:blitRowAt(src, row, baseX + dx) end + end + -- Shaderless boot: the panel is raw grayscale, so there are no palettes to + -- permute and the entry's BRIGHTNESS is the only thing left to reproduce. + if bg.lcdc == "BGP" then + for row = bg.lyStart + 1, math.min(bg.lyEnd, SCREEN_H - 1) do + local veil = BgView.palVeil(bg.lyBackup[row - 1]) + if veil ~= 0 then + local shade = veil > 0 and 0 or 1 + G.setColor(shade, shade, shade, math.min(1, math.abs(veil))) + G.rectangle("fill", 0, row, SCREEN_W, 1) + end + end + G.setColor(1, 1, 1, 1) + end +end + +-- A DMG palette byte's mean shade against the identity %11100100, signed: +-- +1 is solid black ($ff), -1 solid white ($00), 0 the identity. The +-- approximation, kept for the shaderless path in `present` above. +function BgView.palVeil(palByte) + if not palByte then return 0 end + local sum = 0 + for index = 0, 3 do + sum = sum + math.floor(palByte / (4 ^ index)) % 4 + end + return (sum - 6) / 6 +end + +-------------------------------------------------------------------------- +-- The battle intro slide (engine/battle/sliding_intro.asm) +-------------------------------------------------------------------------- + +-- BattleIntroSlidingPics runs 72 frames. It holds three SCX values at once: +-- scanlines 0-$3f take `c`, which starts at $90 and falls by 2 a frame, so +-- the enemy's half enters from the RIGHT; $40-$5f take `b`, which starts at +-- $70 and RISES by 2, wrapping to 0 on the last frame, so the player's half +-- enters from the LEFT; everything from $60 down is already in place. +BgView.SLIDE_FRAMES = 72 + +function BgView.slideOffsets(frame) + local step = math.max(0, math.min(BgView.SLIDE_FRAMES, frame)) + local top = (0x90 - step * 2) % 256 + local middle = (0x70 + step * 2) % 256 + return top, middle +end + +-- The pixels the cart's OAM back-pic copy still has to travel at `frame`: +-- CopyBackpic parks 18 sprites just off the RIGHT edge (x = 168 in OAM +-- terms) and the slide's `.subfunction1` walks every one left 2px a frame, +-- so the whole pic crosses 144px to its resting column in the 72 frames. +function BgView.slideBackpicOffset(frame) + local step = math.max(0, math.min(BgView.SLIDE_FRAMES, frame)) + return (BgView.SLIDE_FRAMES - step) * 2 +end + +-- The same band-at-a-time blit `present` uses, with the intro's own offsets. +-- +-- `drawBackpic(offset)`, when given, draws the player's back pic OVER the +-- bands the way the cart's OAM copy rides over them: InitBattleDisplay clears +-- the pic's top rows out of the BG before the slide (the hlcoord 1, 5 +-- ClearBox) precisely because the pic straddles the $40 scanline where the +-- two bands part ways -- baked into the bands it tears in half there, its top +-- rows riding the enemy's offset and its bottom rows the player's. The +-- caller must leave the pic OUT of drawBg and hand it here instead. +function BgView:presentSlide(frame, drawBg, drawBackpic) + local SCREEN_W=self.bgWidth or 160 + if not (love and love.graphics) then return end + local top, middle = BgView.slideOffsets(frame) + if top == 0 and middle == 0 then + drawBg() + if drawBackpic then drawBackpic(0) end + return + end + local G = love.graphics + self:bake(drawBg, nil) + G.setShader() + self:fillBackground() + G.setColor(1, 1, 1, 1) + for row = 0, SCREEN_H - 1 do + local scx = 0 + if row < 0x40 then + scx = top + elseif row < 0x60 then + scx = middle + end + -- SCX is unsigned and the map wraps at 256, so a value over half the + -- screen reads as "coming in from the other side" rather than as a jump. + local dx = -scx + if scx > 128 then dx = 256 - scx end + self:blitRow(row, dx, 0) + end + if drawBackpic then + drawBackpic(BgView.slideBackpicOffset(frame)) + end +end + +-- A DMG palette byte read as a shade remap, so a BG effect that fades a mon +-- through $f8/$fc can be applied to that mon's four CGB colours. The same +-- CopyPals permutation `present` now folds into the whole panel, which is why +-- it lives in GbcPalette and this is a name for it rather than a second copy. +function BgView.shadeColors(colors, palByte) + return GbcPalette.remap(colors, palByte) +end + +BgView.SCREEN_W = SCREEN_W +BgView.SCREEN_H = SCREEN_H +BgView.needsCanvas = needsCanvas + +function BgView:setBgSize(width) + width=width or 160 + assert(type(width)=='number' and width>0 and width%1==0,'invalid native battle surface width') + if self.bgWidth==width then return end + self:releaseBg() + self.bgWidth=width +end +function BgView:releaseBg() + if self.canvas and self.canvas.release then self.canvas:release() end + self.canvas,self.blitQuad=nil,nil +end + +-- Public draw seams restore the caller's graphics owner; no animation time +-- runs here. The real native scene supplies its transparency and surface. +local present=BgView.present +function BgView:present(runner,drawBg,battle,width) + if not(love and love.graphics)then return end + self:setBgSize(width) + local transparent=self.bgTransparent + self.bgTransparent=battle and type(battle.worldBackdrop)=='function' and battle:worldBackdrop() or false + local g=love.graphics + g.push('all') + local ok,why=pcall(present,self,runner,drawBg,battle) + g.pop() + self.bgTransparent=transparent + if not ok then error(why,0) end +end +local presentSlide=BgView.presentSlide +function BgView:presentSlide(frame,drawBg,drawBackpic,width) + if not(love and love.graphics)then return end + self:setBgSize(width) + local g=love.graphics + g.push('all') + local ok,why=pcall(presentSlide,self,frame,drawBg,drawBackpic) + g.pop() + if not ok then error(why,0) end +end +return BgView diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 0b3b50889..282ff03bb 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -78,9 +78,10 @@ local function inRegion(x, y, w, h, dx, dy, fn) g.setScissor(x, y, w, h) g.push() g.translate(dx, dy) - fn() + local ok,why=pcall(fn) g.pop() restoreScissor(saved) + if not ok then error(why,0)end end local function levelAt(battle, battler, x, y) @@ -449,6 +450,21 @@ local function drawAnimationLayer(battle) end -- The whole 304x144 composition for one frame. +-- Native picture regions are shared by the BG and lifted-OBJ passes. +function WideBattle.drawPicRegions(battle,slide,sx,sy) + sx,sy=sx or 0,sy or 0 + local previous=battle.wideRegion + battle.wideRegion = true + local ok,why=pcall(function() + inRegion(sx, 32 + sy, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy, + function() battle:drawPicsLayer(slide, 0, 0, "player", true) end) + inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy, + function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end) + end) + battle.wideRegion = previous + if not ok then error(why,0)end +end + local function drawPanel(battle,withObjects) local g = love.graphics -- The field is the display mode's paper. Under a forced-mono mode the @@ -461,7 +477,9 @@ local function drawPanel(battle,withObjects) else g.setColor(PaletteFX.paperShade(battle.data)) end - g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT) + if not battle:worldBackdrop()then + g.rectangle("fill",0,0,WideBattle.WIDTH,WideBattle.HEIGHT) + end -- AskName clears the field the same way the classic layout does if battle.blankForAskName then return end @@ -482,12 +500,7 @@ local function drawPanel(battle,withObjects) -- each region's clip with its contents, so a pic pushed toward a region -- edge is not sheared off it (a vertical shake used to clip the player's -- feet at FIELD_BOTTOM). - battle.wideRegion = true - inRegion(sx, 32 + sy, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy, - function() battle:drawPicsLayer(slide, 0, 0, "player", true) end) - inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy, - function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end) - battle.wideRegion = nil + WideBattle.drawPicRegions(battle,slide,sx,sy) -- A battle sets rWY to 0 (engine/battle/core.asm), so the window the -- shakes move IS the whole screen: PredefShakeScreenHorizontally, diff --git a/tests/engine/gen2_bg_presenter.lua b/tests/engine/gen2_bg_presenter.lua new file mode 100644 index 000000000..1a243e48f --- /dev/null +++ b/tests/engine/gen2_bg_presenter.lua @@ -0,0 +1,159 @@ +-- Actual native VM -> scene -> BG presenter. Older upstream uses its +-- native battler draw where the separate public portrait API is absent. Only graphics device calls +-- are recorded. These checks do not claim rendered GPU pixel acceptance. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local View=require('src.battle.Gen2AnimView') +local Bg=require('src.battle.gen2.BgEffects') +local Palette=require('src.render.Gen2PaletteDraw') +local Pictures=require('src.battle.Gen2BgPictures') +local Battle=require('src.battle.BattleState') +local Wide=require('src.battle.WideBattle') +local Pokemon=require('src.pokemon.Pokemon') +local g=love.graphics +local draw,newCanvas,rectangle=g.draw,g.newCanvas,g.rectangle +local calls,rects,allocations,released={},{},0,0 +local previous=Version.get() +g.draw=function(image,quad,x,y,...) + calls[#calls+1]={image=image,quad=quad,x=x,y=y,shader=g.getShader(),byte=Palette.bgp, + viewport=type(quad)=='table'and quad.getViewport and {quad:getViewport()}or nil} +end +g.rectangle=function(...)rects[#rects+1]={...};return rectangle(...)end +g.newCanvas=function(...) + allocations=allocations+1 + local canvas=newCanvas(...) + canvas.release=function()released=released+1 end + return canvas +end +local function native(edition,effect) + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.battle_anims={gen2=true,moveAnims={PROBE=1},scripts={ + [1]={entry=1,code={{op='bgeffect',args={effect or 'BATTLE_BG_EFFECT_BODY_SLAM',0,1,0}}, + {op='delay',frames=16},{op='ret'}}}},objects={},framesets={},oam={},gfx={}} + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={Pokemon.new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.introSlide=0;s.showPlayerBack=false;s.showEnemyTrainer=false + s.sendingOut=false;s.enemySendingOut=false;s.enemyHidden=false + T.check(s:animForId('PROBE','player'),'actual native screen starts its existing animation player') + for _=1,3 do s.animPlayer:update()end + return s +end +for _,edition in ipairs({'gold','silver','crystal'})do + local s=native(edition) + for _,width in ipairs({160,304,160})do + calls,rects={},{} + local external={};g.setShader(external);g.setColor(.2,.3,.4,.5) + local frame=s.anim.frames + if width==304 then Wide.draw(s)else s:drawSceneBody()end + local blits={} + for _,row in ipairs(calls)do + if row.image==s.animView.canvas then blits[#blits+1]=row end + end + T.eq(#blits,144,'native composition blits one actual background row per LCD row') + T.eq(s.animView.canvas:getWidth(),width,'presenter uses the actual classic or wide surface') + T.eq(blits[49].x,2,'native BODY_SLAM shifts the selected LCD band by signed two pixels') + T.eq(blits[49].viewport[3],width-2,'shifted row clips to its own surface width') + T.eq(blits[1].x,0,'row above the LCD window remains fixed') + T.eq(blits[144].x,0,'text rows remain outside the BODY_SLAM window') + for _,row in ipairs(blits)do T.eq(row.shader,nil,'already-coloured canvas rows bypass a caller shader')end + T.eq(s.anim.frames,frame,'scene rendering never advances the native VM') + T.eq(g.getShader(),external,'native composition restores caller shader') + if width==304 then + T.same({g.getColor()},{1,1,1,1},'outer native scene sets neutral tint for final overlays') + else + T.same({g.getColor()},{.2,.3,.4,.5},'native scene body restores caller tint') + end + end + s.animView:release();s.animPlayer:release() +end +-- Paint the actual native battler under picture-size, hide, shade and lifted +-- states. These are graphics-device records, not fabricated gameplay saves. +for _,edition in ipairs({'gold','silver','crystal'})do + local s=native(edition,'BATTLE_BG_EFFECT_ENTER_MON') + local b=s.player + local image=s:battlerPic(b);local w,h=image:getDimensions() + calls={} + s:drawBattlerPic(b,10,20,1) + local row=calls[#calls] + T.check(row~=nil,'native entry still paints its actual battler') + T.eq(row.quad,10+math.floor(w*(1-1/3)/2),'entry picture compensation stays on integer native pixels') + T.eq(row.x,20+math.floor(h*(1-1/3)),'entry keeps its bottom edge pinned to the native box') + local bg=s.anim.bg;bg.picSize.player=nil + bg.monShade.player=0xf8;bg.monShade.enemy=0xfc + Palette.setBgp(0x1b);calls={} + if s.drawPic then s:drawPic(b.mon,false)else s:drawBattlerPic(b,10,20,1)end + T.check(#calls>0,'front capture of the actual player remains drawable') + for _,paint in ipairs(calls)do + T.eq(paint.byte,Pictures.paletteByte(0xf8,0x1b),'player front capture uses player shade and composes it once') + end + T.eq(Palette.bgp,0x1b,'picture capture restores the surrounding BG register') + Palette.setBgp(nil) + bg.hidden.player=true;calls={} + s:drawBattlerPic(b,10,20,1) + T.eq(#calls,0,'actual BG hide state suppresses the native battler draw') + bg.hidden.player=false;bg.liftedRows.player={0,1} + local scissor=g.setScissor;local clips={} + g.setScissor=function(...)clips[#clips+1]={...};return scissor(...)end + calls={};s:drawBattlerPic(b,10,20,1) + T.same(clips[1],{0,0,160,48},'native BG paints only above the lifted player row') + T.same(clips[2],{0,56,160,88},'native BG paints only below the lifted player row') + T.eq(#calls,2,'lifted row is excluded from the actual BG picture pass') + clips={};calls={};s.liftedPass=true;s:drawBattlerPic(b,10,20,1);s.liftedPass=nil + T.same(clips[1],{0,48,160,8},'lifted OBJ pass selects the original player tile row') + T.eq(#calls,1,'lifted row is restored exactly once outside BG distortion') + g.setScissor=scissor + local external={};g.setShader(external);Palette.setBgp(0x1b) + local record=g.draw;g.draw=function()error('native portrait failure',0)end + T.raises(function() + if s.drawPic then s:drawPic(b.mon,false)else s:drawBattlerPic(b,10,20,1)end + end,'native portrait failure','picture error remains visible') + T.eq(Palette.bgp,0x1b,'failed portrait restores its BG palette owner') + T.eq(s._gen2PicPaletteSide,nil,'failed portrait retires its per-side palette context') + T.eq(g.getShader(),external,'failed portrait restores caller shader') + g.draw=record;g.setShader();Palette.setBgp(nil);s.animPlayer:release() +end +T.check(released>=9,'changing surface and releasing native views retires owned canvases') +Version.set('crystal') +do + local v=View.new() + local pool=Bg.new({},{});pool.scx=2 + local screen={worldBackdrop=function()return true end} + calls,rects={},{} + local seen=0 + v:present({bg=pool},function()seen=seen+1 end,screen) + T.eq(seen,1,'transparent scene still draws its actual supplied panel') + T.eq(#rects,0,'exposed scanlines do not paint paper over a live world') + local external={};local target={};g.setShader(external);g.setCanvas(target) + g.setColor(.2,.4,.6,.8);Palette.setBgp(0x1b) + T.raises(function()v:present({bg=pool},function() + g.setShader();g.setColor(1,0,0,1);error('native panel failure',0) + end)end,'native panel failure','actual panel failure remains visible') + T.eq(g.getShader(),external,'failed BG bake restores caller shader') + T.eq(g.getCanvas(),target,'failed BG bake restores caller canvas') + T.same({g.getColor()},{.2,.4,.6,.8},'failed BG bake restores caller tint') + T.eq(Palette.bgp,0x1b,'failed BG bake restores native palette register') + g.setCanvas();g.setShader();Palette.setBgp(nil) + local bg=Bg.new({},{});bg.lcdc='BGP';bg.lyStart=0;bg.lyEnd=4 + for i=0,3 do bg.lyBackup[i]=0xff end + local bands=View.bgpBands(bg) + T.eq(#bands,2,'LCD palette bands are grouped by their actual byte') + T.eq(bands[1].byte,0xe4,'identity palette band is baked first') + T.eq(bands[2].rows[1],1,'LCD applies the prior backup byte starting one row after LY start') + T.eq(bands[2].rows[4],4,'LCD window includes its final scanline') + v:release() +end +for _,monByte in ipairs({0xe4,0x00,0xff,0xf8,0xfc,0x1b})do + for _,bgByte in ipairs({0xe4,0x00,0xff,0x1b})do + local colors={{0},{1},{2},{3}} + local expected=Palette.remap(Palette.remap(colors,monByte),bgByte) + local actual=Palette.bgpShades(Pictures.paletteByte(monByte,bgByte)) + for i=1,4 do T.eq(actual[i],expected[i][1],'native mon and BG palette composition retains index ordering')end + end +end +g.draw,g.newCanvas,g.rectangle=draw,newCanvas,rectangle +g.setShader();g.setCanvas();g.setColor(1,1,1,1);Palette.setBgp(nil);Version.set(previous) +T.finish('native GSC BG row presentation, wide composition, transparency and state restoration') diff --git a/tests/engine/gen2_native_bg_clock.lua b/tests/engine/gen2_native_bg_clock.lua index c8cb51d07..e60a10a37 100644 --- a/tests/engine/gen2_native_bg_clock.lua +++ b/tests/engine/gen2_native_bg_clock.lua @@ -1,9 +1,9 @@ --- Exercise real native VM commands and its actual BG register owner. --- Official main has no Gen2AnimView observation; no substitute runner is installed. +-- Exercise real native VM commands and the public animation observation. -- Expected picture and LCD traces come from the pinned GSC BG implementation. package.path='./?.lua;./?/init.lua;'..package.path local T=require('tests.modkit') local Player=require('src.battle.Gen2AnimPlayer') +local View=require('src.battle.Gen2AnimView') local Version=require('src.core.GameVersion') local previous=Version.get() local function data(code,constants) @@ -18,7 +18,7 @@ for _,edition in ipairs({'gold','silver','crystal'})do {op='delay',frames=13},{op='ret'}, })) T.check(p:start('PROBE',playerTurn),'native VM starts the real test program') - local r=p + local r=View.runner(p);p.presentation=r T.eq(r.bg,p.bg,'observation exposes the actual register owner') local ticks=0;local playFrame=p.bg.playFrame p.bg.playFrame=function(self,...)ticks=ticks+1;return playFrame(self,...)end @@ -29,6 +29,7 @@ for _,edition in ipairs({'gold','silver','crystal'})do local expected=stage<3 and((playerTurn and 2 or 5)-stage)or nil T.eq(r.bg.picSize[side],expected,'native command/wait path follows four-frame picture stages') T.eq(ticks,frame,'one existing VM update executes exactly one BG pass') + T.eq(r.frames,frame,'public time is the native VM time') for _=1,20 do local bg,frames=r.bg,r.frames end T.eq(ticks,frame,'repeated consumer observations never run the effect') end @@ -37,7 +38,7 @@ for _,edition in ipairs({'gold','silver','crystal'})do T.eq(ticks,14,'legacy object-tail drain cannot keep advancing a stopped BG program') local old=r.bg p:start('PROBE',not playerTurn) - T.eq(old.picSize[side],nil,'previous register block keeps its completed picture trace') + T.eq(r.bg,old,'retired consumer retains the previous register block') T.check(p.bg~=old,'restart owns a fresh block rather than mutating retired state') T.eq(p.bg.env.battleTurn,playerTurn and 1 or 0,'restart binds the current attacker side') T.eq(p.bg.picSize.player,nil,'restart clears player picture stages') @@ -50,9 +51,7 @@ for _,edition in ipairs({'gold','silver','crystal'})do local entry=p.bg.effects[1] T.eq(entry.func,edition=='crystal'and'BATTLE_BG_EFFECT_BODY_SLAM'or'BATTLE_BG_EFFECT_WOBBLE_MON', 'raw command resolves through the actual cartridge table') - local effect=p:pollEffects()[1] - T.eq(effect and effect.effect,edition=='crystal'and'SE_MOVE_MON_HORIZONTALLY'or'SE_SHAKE_BACK_AND_FORTH', - 'existing visual adapter also resolves Crystal insertion by effect name') + T.eq(#p:pollEffects(),0,'actual BG owner does not duplicate its motion through legacy SE effects') p:update();p:update() if edition=='crystal'then T.eq(p.bg.lyBackup[0x30],0xfe,'real VM drives the signed BODY_SLAM LCD trace')end p:release() diff --git a/tests/love_stub.lua b/tests/love_stub.lua index b7fc25fa9..7b61f0737 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -44,7 +44,15 @@ stub.graphics = { local w, h = pngSize(path) return setmetatable({ w = w, h = h, path = path }, Image) end, - newQuad = function(x, y, w, h) return { x = x, y = y, w = w, h = h } end, + newQuad = function(x,y,w,h,sw,sh) + local quad={x=x,y=y,w=w,h=h,sw=sw,sh=sh} + function quad:setViewport(a,b,c,d,e,f) + self.x,self.y,self.w,self.h=a,b,c,d + self.sw,self.sh=e or self.sw,f or self.sh + end + function quad:getViewport()return self.x,self.y,self.w,self.h end + return quad + end, newCanvas = function(w, h) return setmetatable({ w = w, h = h, setFilter = noop }, Image) end, From 72f0152d36cfb1e6b134d43be04894f291ba44f6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:18:42 +0100 Subject: [PATCH 078/187] Retain wide-panel fallback for native consumers without a backdrop provider --- src/battle/WideBattle.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 282ff03bb..71b4bcafc 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -477,7 +477,7 @@ local function drawPanel(battle,withObjects) else g.setColor(PaletteFX.paperShade(battle.data)) end - if not battle:worldBackdrop()then + if not(type(battle.worldBackdrop)=='function'and battle:worldBackdrop())then g.rectangle("fill",0,0,WideBattle.WIDTH,WideBattle.HEIGHT) end -- AskName clears the field the same way the classic layout does From bfa3c5bd268b7d5c04c1c335812e6482cec7528f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:21:30 +0100 Subject: [PATCH 079/187] Fold transient GSC BGP into actual native picture pixels with bounded register maps --- src/battle/BattleState.lua | 19 ++++++++++++++++++- tests/engine/gen2_display_modes.lua | 7 +++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index a4eb11706..5b0558af5 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -875,6 +875,10 @@ local function imagePathOf(img) end -- the image a battler pic actually draws with this frame +-- The native image cache keeps static display bakes separate from transient +-- hardware BGP. Resolve that byte at the actual battle picture draw boundary. +-- At most 256 immutable index maps; no per-picture register-map allocation. +local gen2PicRegisterMaps={} function BattleState:picImage(img) local PaletteFX = require("src.render.PaletteFX") if GameVersion.isGen2() then @@ -913,7 +917,20 @@ function BattleState:picImage(img) if self.blackedOut or (self.introSlide or 0) > 0 then return blackImage(self.data, img) end - return fadeImage(img, self:activeBgp()) + img=fadeImage(img,self:activeBgp()) + if GameVersion.isGen2()then + local byte=PaletteFX.bgp() + if byte then + local map=gen2PicRegisterMaps[byte] + if not map then + local shades=PaletteFX.bgpShades(byte) + map={[0]=shades[1],shades[2],shades[3],shades[4]} + gen2PicRegisterMaps[byte]=map + end + img=fadeImage(img,map) + end + end + return img end -- Gen 1 trainer Pokémon have fixed DVs (engine/battle/core.asm); diff --git a/tests/engine/gen2_display_modes.lua b/tests/engine/gen2_display_modes.lua index a9b181046..05aef3eec 100644 --- a/tests/engine/gen2_display_modes.lua +++ b/tests/engine/gen2_display_modes.lua @@ -86,6 +86,13 @@ for _,edition in ipairs({'gold','silver','crystal'})do pixels(tiles.anims[1].textures[1],wanted,0,false,edition..' actual animated tile '..name) pixels(sprite:resolveImage(),wanted,0,true,edition..' retained native sprite '..name) pixels(battle:picImage(originalPic),wanted,0,false,edition..' retained native battle picture '..name) + G.setBgp(0x1b) + pixels(battle:picImage(originalPic),{wanted[4],wanted[3],wanted[2],wanted[1]},0,false, + edition..' actual transient BG register reaches baked battle pixels '..name) + battle.fx={bgp={[0]=3,2,1,0}} + pixels(battle:picImage(originalPic),wanted,0,false, + edition..' actual BG register composes after legacy picture fade '..name) + battle.fx=nil;G.setBgp(nil) battle.fx={bgp={[0]=3,2,1,0}} pixels(battle:picImage(originalPic),{wanted[4],wanted[3],wanted[2],wanted[1]},0,false,edition..' battle fade after display '..name) battle.fx=nil;battle.blackedOut=true From 7fc2adcf0fe120be24808acc1976d88da623af68 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:24:24 +0100 Subject: [PATCH 080/187] Consume native OBP0 in the actual GSC gray and yellow OAM palette path --- src/battle/Gen2AnimView.lua | 12 ++++-- tests/engine/gen2_bg_object_palette.lua | 51 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 tests/engine/gen2_bg_object_palette.lua diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua index ff21a0533..d1a0f7d5c 100644 --- a/src/battle/Gen2AnimView.lua +++ b/src/battle/Gen2AnimView.lua @@ -1,6 +1,8 @@ -- Native object drawing over Gen2AnimPlayer's actual OAM projection. -- Object layout/flip math retains Gen2AnimPlayer provenance (Cedric); -- public owner integration by Roxas2712, see NOTICE.md and contribution licence. +-- GSC BG/OBJ register integration follows Gen1Recomp535a5ec0 under +-- compat/gen1-gsc/LICENSE.MD; inherited object-drawing rights remain intact. local Palette = require('src.render.Gen2PaletteDraw') local PaletteFX = require('src.render.PaletteFX') local View = {} @@ -58,16 +60,18 @@ function View:drawObjects(runner, battle) g.push('all') local ok, err = pcall(function() g.setColor(1,1,1,1) - local active + local active,activeByte for _, obj in ipairs(runner:oam()) do local sheet = self.data.gfx and self.data.gfx[obj.gfx] local image = sheet and self:image(sheet.image) local quad = image and self:quad(obj.gfx, obj.tile, image) if quad then local colors = self:objPalette(obj.palette, battle) - if colors ~= active then - if not Palette.useRaw(Palette.resolve(colors)) then g.setShader() end - active = colors + local slot=slots[obj.palette]or tonumber(obj.palette) + local byte=(slot==2 or slot==3)and runner.bg and runner.bg.obp0 or nil + if colors~=active or byte~=activeByte then + if not Palette.useRaw(Palette.remap(Palette.resolve(colors),byte))then g.setShader()end + active,activeByte=colors,byte end local attr = obj.attr or 0 local sx = math.floor(attr/32)%2 == 1 and -1 or 1 diff --git a/tests/engine/gen2_bg_object_palette.lua b/tests/engine/gen2_bg_object_palette.lua new file mode 100644 index 000000000..c9cc8b774 --- /dev/null +++ b/tests/engine/gen2_bg_object_palette.lua @@ -0,0 +1,51 @@ +-- Real native OBP command -> OAM -> indexed shader uniforms. Device records +-- observe the native draw without replacing animation or palette owners. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Player=require('src.battle.Gen2AnimPlayer') +local Palette=require('src.render.Gen2PaletteDraw') +local Version=require('src.core.GameVersion') +local g=love.graphics +local shader,draw=g.newShader,g.draw +local previous=Version.get() +g.newShader=function() + return {values={},send=function(self,name,value)self.values[name]=value end} +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local A={{255,255,255},{201,53,71},{43,157,89},{0,0,0}} + local B={{245,239,227},{31,97,193},{167,59,131},{7,13,19}} + local C={{221,231,241},{71,81,91},{101,111,121},{11,21,31}} + local data={battle_anims={gen2=true,moveAnims={PROBE=1}, + scripts={[1]={entry=1,code={{op='obp0',args={0x1b}}, + {op='obj',args={0,64,48,0}},{op='obj',args={1,72,48,0}}, + {op='obj',args={2,80,48,0}},{op='delay',frames=2},{op='ret'}}}}, + objects={},functions={[0]='Null'},framesets={[0]={frames={{oam=0,duration=8}},loop='hold'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}}}, + gfx={[1]={tiles=1,cols=1,image='native-obj'}},palettes={[0]=A,[1]=B,[2]=C}}} + for id=0,2 do data.battle_anims.objects[id]={fn=0,frameset=0,palette=id+2,gfx=1}end + local p=Player.new(data);T.check(p:start('PROBE',true),'native object-palette program starts') + p:update();T.eq(p.bg.obp0,0x1b,'native command owns the actual OBP0 register') + local records={} + g.draw=function() + local active=g.getShader();local row={} + for i=0,3 do row[i+1]={unpack(active.values['pal'..i])}end + records[#records+1]=row + end + local external={};g.setShader(external) + Palette.setBgp(0x00) -- the already-coloured BG register must not fade OBJs + local frame=p.frames;p:draw() + T.eq(#records,3,'all three actual native OAM objects are drawn') + for index,colors in ipairs({A,B,C})do + local expected=Palette.remap(Palette.resolve(colors),index<=2 and 0x1b or nil) + for i=1,4 do + T.same(records[index][i],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255}, + 'only native gray/yellow OBJ slots consume OBP0, with exact RGB uniforms') + end + end + T.eq(p.frames,frame,'OBJ palette drawing never advances the VM') + T.eq(g.getShader(),external,'OBJ pass restores caller shader') + Palette.setBgp(nil);g.setShader();p:release() +end +g.newShader,g.draw=shader,draw;Version.set(previous) +T.finish('native GSC OBP command, OAM palette selection and exact shader uniforms') From 7d8cd141ea808648bfc5e5d8d11fe63563f59639 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:38:46 +0100 Subject: [PATCH 081/187] Compose public picture-state overrides with the actual native BG observation --- src/battle/Gen2BgPictures.lua | 24 +++++++++++++----------- tests/engine/gen2_bg_presenter.lua | 7 +++++++ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/battle/Gen2BgPictures.lua b/src/battle/Gen2BgPictures.lua index fb2651ff6..c06a956a7 100644 --- a/src/battle/Gen2BgPictures.lua +++ b/src/battle/Gen2BgPictures.lua @@ -29,16 +29,18 @@ local function withPalette(screen,side,body) if not ok then error(why,0)end end function M.install(Screen) - -- Older native screens need the same live observation; this reads their - -- actual battler and BG owners rather than constructing another model. - if not Screen.animPicState then - function Screen:animPicState(side) - local bg=self.anim and self.anim.bg - if not bg then return nil end - return {hidden=bg.hidden[side],size=bg.picSize[side], - shade=bg.monShade[side],slide=bg.slide[side]or 0, - lifted=bg.liftedRows[side]} - end + -- Compose the native picture observation with the real BG owner. A later + -- public method override remains visible to the actual picture drawer. + local read=Screen.animPicState + function Screen:animPicState(side) + local state=read and read(self,side) + local bg=self.anim and self.anim.bg + if not bg then return state end + state=state or {} + state.hidden=state.hidden or bg.hidden[side] + state.size,state.shade=bg.picSize[side],bg.monShade[side] + state.slide,state.lifted=bg.slide[side]or 0,bg.liftedRows[side] + return state end local draw=Screen.drawBattlerPic function Screen:drawBattlerPic(b,x,y,scale) @@ -49,7 +51,7 @@ function M.install(Screen) local state=self:animPicState(side) if state and state.hidden then return end if self.liftedPass and not(state and state.lifted)then return end - if not self.liftedPass then x=x+(self.anim.bg.slide[side] or 0)end + if not self.liftedPass then x=x+(state and state.slide or 0)end local size=state and SIZES[state.size] if size then local image=self:battlerPic(b) diff --git a/tests/engine/gen2_bg_presenter.lua b/tests/engine/gen2_bg_presenter.lua index 1a243e48f..147e85ea7 100644 --- a/tests/engine/gen2_bg_presenter.lua +++ b/tests/engine/gen2_bg_presenter.lua @@ -83,6 +83,13 @@ for _,edition in ipairs({'gold','silver','crystal'})do T.eq(row.quad,10+math.floor(w*(1-1/3)/2),'entry picture compensation stays on integer native pixels') T.eq(row.x,20+math.floor(h*(1-1/3)),'entry keeps its bottom edge pinned to the native box') local bg=s.anim.bg;bg.picSize.player=nil + local read=s.animPicState + s.animPicState=function(self,side) + local state=read(self,side);state.slide=3;return state + end + calls={};s:drawBattlerPic(b,10,20,1) + T.eq(calls[#calls].quad,13,'public picture-state slide override reaches the actual native draw') + s.animPicState=read bg.monShade.player=0xf8;bg.monShade.enemy=0xfc Palette.setBgp(0x1b);calls={} if s.drawPic then s:drawPic(b.mon,false)else s:drawBattlerPic(b,10,20,1)end From b38daddde51b4eafa1b37524683bf5c8ea1f4cd1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:47:10 +0100 Subject: [PATCH 082/187] Keep GSC picture hooks composed after native view installation --- src/battle/BattleState.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 5b0558af5..690b5ceac 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -13047,6 +13047,8 @@ end require('src.battle.Gen2MovePresentation').install(BattleState) require('src.battle.Gen2BattleScene').install(BattleState) + +-- Compose after native/source view installers, retaining their real draw owners. require('src.battle.Gen2BgPictures').install(BattleState) return BattleState From ec40dddc6f820032ea4b30c4d49a65f66bcef931 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:20:06 +0100 Subject: [PATCH 083/187] Execute native GSC commands and object state machines through one real VM --- compat/gen1-gsc/provenance.json | 14 +- src/battle/Gen2AnimData.lua | 83 + src/battle/Gen2AnimObjectIds.lua | 600 +++++++ src/battle/Gen2AnimPlayer.lua | 901 ++-------- src/battle/Gen2AnimView.lua | 61 +- src/battle/gen2/AnimObjects.lua | 2023 ++++++++++++++++++++++ src/battle/gen2/AnimRunner.lua | 485 ++++++ src/import/RomExtractorGen2.lua | 13 +- src/import/RomImporter.lua | 4 + tests/engine/gen2_animation_identity.lua | 10 +- tests/engine/gen2_animation_objects.lua | 28 +- tests/engine/gen2_bg_object_palette.lua | 2 +- tests/engine/gen2_native_bg_clock.lua | 7 +- 13 files changed, 3362 insertions(+), 869 deletions(-) create mode 100644 src/battle/Gen2AnimData.lua create mode 100644 src/battle/Gen2AnimObjectIds.lua create mode 100644 src/battle/gen2/AnimObjects.lua create mode 100644 src/battle/gen2/AnimRunner.lua diff --git a/compat/gen1-gsc/provenance.json b/compat/gen1-gsc/provenance.json index 33c3e99c5..d8931f7b6 100644 --- a/compat/gen1-gsc/provenance.json +++ b/compat/gen1-gsc/provenance.json @@ -11,17 +11,21 @@ "LICENSE.MD": "b369ce45656b3d87453541816fac6982b13e09ea0734ab9cfe61cf3faf5e95ed", "src/ui/gen2/BattleAnimView.lua": "c67613148f14987bdc76d01ace2cd90e89f7d742e4c4a0e95889707f5a9d0e61", "src/ui/gen2/BattleState.lua": "6789c83e5124701d7ebfb1bdc74389102fa85c02f5ecdb812baf0905bded723c", - "src/ui/gen2/Chrome.lua": "a81231beac5334eae58df0936fed1f4fec90b3d986e8caf8b31b5e522180dc52" + "src/ui/gen2/Chrome.lua": "a81231beac5334eae58df0936fed1f4fec90b3d986e8caf8b31b5e522180dc52", + "src/battle/gen2/AnimRunner.lua": "4f31c23a94e14d5796a125237ab205342e6b3b63134915cda67373005536ce71" }, - "scope": "BgEffects, BG presenter, arithmetic subset and BG-effect identifier lists. No launcher, game assets, ROM bytes or player state copied.", + "scope": "GSC BG and object state machines, command VM, BG presenter, arithmetic and identifier orders. No launcher, game assets, ROM bytes or player state copied.", "modifications": [ "BgEffects arithmetic dependency uses Gen2AnimMath instead of the full reference object pool.", "A missing jump-table list resolves through edition-specific Gen2BgEffectIds for older native caches.", "Existing Gen2AnimPlayer dispatches BG/palette commands and steps the pool from its current VM update. Native scene and picture owners consume these registers directly, replacing the legacy SE approximations.", "BG presenter uses native palette/chrome/canvas owners, supports the real wide surface, preserves transparency and restores graphics state on supplied-panel errors.", - "Battler resize, hide, palette and lifted-row drawing remain attached to native image owners. Object function motion and complete send-out event sequencing remain separate work.", - "The BG-only paper and clipping subset uses the native indexed palette owner, without requiring the full menu Chrome module on older engines." + "Battler resize, hide, palette and lifted-row drawing remain attached to native image owners. The genuine object pool and command VM now drive native OAM; complete send-out event sequencing remains separate work.", + "The BG-only paper and clipping subset uses the native indexed palette owner, without requiring the full menu Chrome module on older engines.", + "Full object pool uses the already pinned native Gen2AnimMath sine/cosine subset. Native imported numeric animation tables are converted without inventing ROM programs or changing their operands.", + "Existing native animation player now uses the complete command interpreter and object pool as its sole clock. The native view consumes emitted OAM through the actual loaded GFX dictionary; no interpolated motion, 48-frame age cap or 16-frame drain remains.", + "The native importer preserves the crossed battler-row macro/handler names. A Gen2-only cache revision rejects old generated commands and preserves game saves." ], "licence": "Gen1Recomp GPLv3 with its Additional Terms, retained in LICENSE.MD. Existing authors retain their rights; Roxas2712 integration changes in these derived files are offered on those applicable terms.", - "qualification": "CPU pool, SDK entry, native VM and classic/wide BG rendering contracts tested. One unchanged-VASC Crystal Metal battle reached a native faint, win, EXP award and world return with real window captures on ce12c485. Full object motion, send-out sequencing, all effects GPU frames, other platforms and release qualification remain required." + "qualification": "ROM-free actual-pixel counterexample fails 366 checks on the preceding native player; replacement passes. Initial actual Crystal ROM comparison matches 251 move and 23 extra-animation programs on both sides with four parameters (2192 cases); six high-parameter cases reach the observation limit equally on both interpreters. Expanded traces, new GPU frames, Windows and packaged release qualification remain pending. Earlier Metal battle ce12c485 predates the object/VM replacement." } diff --git a/src/battle/Gen2AnimData.lua b/src/battle/Gen2AnimData.lua new file mode 100644 index 000000000..c178162bc --- /dev/null +++ b/src/battle/Gen2AnimData.lua @@ -0,0 +1,83 @@ +-- Native imported animation tables -> genuine GSC runtime data. +-- Identifier/command formats derive from Gen1Recomp535a5ec0 under GPLv3 +-- plus Additional Terms; see compat/gen1-gsc/. No ROM program is invented. +local Ids=require('src.battle.Gen2AnimObjectIds') +local M={} +M.CACHE_REVISION=1 +local function order(constants,key) + return constants and constants[key] or Ids[key] +end +function M.tables(anims,constants) + anims=anims or {} + local c={} + for _,key in ipairs({'battleAnimFramesetOrder','battleAnimObjectOrder', + 'battleAnimFuncOrder','battleAnimGfxOrder','battleAnimObPaletteOrder'})do c[key]=order(constants,key)end + c.battleBgEffectOrder=constants and constants.battleBgEffectOrder + or require('src.battle.Gen2BgEffectIds').forVersion() + local data={objects={},framesets={},oamsets={},gfx={},palettes=anims.palettes} + for id,row in pairs(anims.objects or {})do + local name=c.battleAnimObjectOrder[id+1]or id + local native=anims.functions and anims.functions[row.fn] + local fn=constants and constants.battleAnimFuncOrder + and constants.battleAnimFuncOrder[(row.fn or 0)+1] + or Ids.nativeFunctions[native] or native + or c.battleAnimFuncOrder[(row.fn or 0)+1] + data.objects[name]={flags=row.oamFlags,fixY=row.fixY, + frameset=c.battleAnimFramesetOrder[row.frameset+1]or row.frameset, + func=fn,palette=c.battleAnimObPaletteOrder[(row.palette or 0)+1]or row.palette, + gfx=c.battleAnimGfxOrder[(row.gfx or 0)+1]or row.gfx} + end + for id,set in pairs(anims.framesets or {})do + local rows={} + for _,frame in ipairs(set.frames or {})do + rows[#rows+1]=frame.wait and {'wait',frame.duration or 0} + or {'frame',frame.oam,frame.duration or 0, + (frame.xflip and 0x20 or 0)+(frame.yflip and 0x40 or 0)} + end + rows[#rows+1]={set.loop=='restart'and 'restart' + or set.loop=='delete'and 'delete'or 'end'} + data.framesets[c.battleAnimFramesetOrder[id+1]or id]=rows + end + for id,row in pairs(anims.oam or {})do + data.oamsets[id]={vtile=row.tile,sprites=row.sprites} + end + for id,sheet in pairs(anims.gfx or {})do + data.gfx[c.battleAnimGfxOrder[id+1]or id]=sheet + end + return data,c +end + +local ops={delay='wait',ifvarequal='if_var_equal', + ifparamequal='if_param_equal',ifparamand='if_param_and'} +-- Native scripts already contain all reached subroutines in one flat ROM +-- walk. A target remains that walk's instruction index; suffix lists let the +-- GSC interpreter fetch it without synthesizing addresses or instructions. +function M.program(tables,script) + local data={} + for key,value in pairs(tables)do data[key]=value end + data.scripts={} + if not script then return data end + local rows,targets={},{} + targets[script.entry or 1]=true + for i,inst in ipairs(script.code or {})do + local op=ops[inst.op]or inst.op + local row={op} + for _,value in ipairs(inst.args or {})do row[#row+1]=type(value)=='number'and value%256 or value end + if op=='wait'then row[2]=inst.frames or 0 + elseif op=='gfx'then + row={tostring(#(inst.ids or {}))..'gfx'} + for _,id in ipairs(inst.ids or {})do row[#row+1]=id end + elseif op=='jump'or op=='call'or op=='jumpuntil'then row[2]=inst.to + elseif op=='loop'or op=='if_var_equal'or op=='if_param_equal' + or op=='if_param_and'then row[3]=inst.to end + if inst.to then targets[inst.to]=true end + rows[i]=row + end + for start in pairs(targets)do + local suffix={} + for i=start,#rows do suffix[#suffix+1]=rows[i]end + data.scripts[start]=suffix + end + return data,script.entry or 1 +end +return M diff --git a/src/battle/Gen2AnimObjectIds.lua b/src/battle/Gen2AnimObjectIds.lua new file mode 100644 index 000000000..15c35121b --- /dev/null +++ b/src/battle/Gen2AnimObjectIds.lua @@ -0,0 +1,600 @@ +-- GSC identifier order from pinned Gen1Recomp535a5ec0 manifests. +-- GPLv3 plus Additional Terms; compat/gen1-gsc/LICENSE.MD. +-- Gold, Silver and Crystal have identical orders for these tables. +local M={} +M.battleAnimFramesetOrder={ + "BATTLE_ANIM_FRAMESET_HIT_BIG", + "BATTLE_ANIM_FRAMESET_HIT", + "BATTLE_ANIM_FRAMESET_HIT_SMALL", + "BATTLE_ANIM_FRAMESET_PUNCH", + "BATTLE_ANIM_FRAMESET_KICK", + "BATTLE_ANIM_FRAMESET_PALM", + "BATTLE_ANIM_FRAMESET_FANG", + "BATTLE_ANIM_FRAMESET_PUNCH_SHAKE", + "BATTLE_ANIM_FRAMESET_BALL_POOF", + "BATTLE_ANIM_FRAMESET_POKE_BALL_1", + "BATTLE_ANIM_FRAMESET_POKE_BALL_2", + "BATTLE_ANIM_FRAMESET_POKE_BALL_3", + "BATTLE_ANIM_FRAMESET_POKE_BALL_4", + "BATTLE_ANIM_FRAMESET_POKE_BALL_5", + "BATTLE_ANIM_FRAMESET_DRAGON_RAGE", + "BATTLE_ANIM_FRAMESET_FLAMETHROWER", + "BATTLE_ANIM_FRAMESET_EMBER", + "BATTLE_ANIM_FRAMESET_BURNED", + "BATTLE_ANIM_FRAMESET_BLIZZARD", + "BATTLE_ANIM_FRAMESET_ICE", + "BATTLE_ANIM_FRAMESET_ICE_BEAM", + "BATTLE_ANIM_FRAMESET_POWDER_SNOW", + "BATTLE_ANIM_FRAMESET_RAZOR_LEAF_1", + "BATTLE_ANIM_FRAMESET_RAZOR_LEAF_2", + "BATTLE_ANIM_FRAMESET_EXPLOSION", + "BATTLE_ANIM_FRAMESET_BIG_ROCK", + "BATTLE_ANIM_FRAMESET_SMALL_ROCK", + "BATTLE_ANIM_FRAMESET_STRENGTH", + "BATTLE_ANIM_FRAMESET_SKULL_CROSSBONE", + "BATTLE_ANIM_FRAMESET_ACID", + "BATTLE_ANIM_FRAMESET_POISON_DROPLET_UNUSED", + "BATTLE_ANIM_FRAMESET_SLUDGE_BUBBLE", + "BATTLE_ANIM_FRAMESET_SLUDGE_BUBBLE_BURST", + "BATTLE_ANIM_FRAMESET_SMALL_BUBBLE", + "BATTLE_ANIM_FRAMESET_PULSING_BUBBLE", + "BATTLE_ANIM_FRAMESET_SURF", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_1", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_2", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_3", + "BATTLE_ANIM_FRAMESET_WATER_GUN_1", + "BATTLE_ANIM_FRAMESET_WATER_GUN_2", + "BATTLE_ANIM_FRAMESET_WATER_GUN_3", + "BATTLE_ANIM_FRAMESET_HYDRO_PUMP", + "BATTLE_ANIM_FRAMESET_POWDER", + "BATTLE_ANIM_FRAMESET_BEAM", + "BATTLE_ANIM_FRAMESET_BEAM_TIP", + "BATTLE_ANIM_FRAMESET_ICE_BUILDUP", + "BATTLE_ANIM_FRAMESET_FROZEN", + "BATTLE_ANIM_FRAMESET_CIRCLING_SPARKLE", + "BATTLE_ANIM_FRAMESET_THUNDER_CENTER", + "BATTLE_ANIM_FRAMESET_THUNDER_LEFT", + "BATTLE_ANIM_FRAMESET_THUNDER_RIGHT", + "BATTLE_ANIM_FRAMESET_THUNDER_WAVE_DISABLE", + "BATTLE_ANIM_FRAMESET_THUNDER_WAVE_EXTRA", + "BATTLE_ANIM_FRAMESET_THUNDERBOLT_SPARKS", + "BATTLE_ANIM_FRAMESET_THUNDERBOLT_CORE", + "BATTLE_ANIM_FRAMESET_THUNDERSHOCK_SPARKS", + "BATTLE_ANIM_FRAMESET_THUNDERSHOCK_CORE", + "BATTLE_ANIM_FRAMESET_CLAMP", + "BATTLE_ANIM_FRAMESET_CLAMP_FLIPPED", + "BATTLE_ANIM_FRAMESET_BITE_1", + "BATTLE_ANIM_FRAMESET_BITE_2", + "BATTLE_ANIM_FRAMESET_CUT_DOWN_LEFT", + "BATTLE_ANIM_FRAMESET_CUT_DOWN_RIGHT", + "BATTLE_ANIM_FRAMESET_CUT_UP_RIGHT", + "BATTLE_ANIM_FRAMESET_CUT_LONG_DOWN_LEFT", + "BATTLE_ANIM_FRAMESET_CUT_LONG_DOWN_RIGHT", + "BATTLE_ANIM_FRAMESET_CHARGE_ORB_1", + "BATTLE_ANIM_FRAMESET_ABSORB_CENTER", + "BATTLE_ANIM_FRAMESET_GUST", + "BATTLE_ANIM_FRAMESET_VINE_WHIP_1", + "BATTLE_ANIM_FRAMESET_VINE_WHIP_2", + "BATTLE_ANIM_FRAMESET_RAZOR_WIND_1", + "BATTLE_ANIM_FRAMESET_RAZOR_WIND_2", + "BATTLE_ANIM_FRAMESET_SONICBOOM_JP", + "BATTLE_ANIM_FRAMESET_WARP", + "BATTLE_ANIM_FRAMESET_CHARGE_ORB_2", + "BATTLE_ANIM_FRAMESET_EGG", + "BATTLE_ANIM_FRAMESET_EGG_WOBBLE", + "BATTLE_ANIM_FRAMESET_EGG_CRACKED_TOP", + "BATTLE_ANIM_FRAMESET_EGG_CRACKED_BOTTOM", + "BATTLE_ANIM_FRAMESET_FOCUS", + "BATTLE_ANIM_FRAMESET_BIND_1", + "BATTLE_ANIM_FRAMESET_BIND_2", + "BATTLE_ANIM_FRAMESET_BIND_3", + "BATTLE_ANIM_FRAMESET_BIND_4", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_1", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_2", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_3", + "BATTLE_ANIM_FRAMESET_SOUND_1", + "BATTLE_ANIM_FRAMESET_SOUND_2", + "BATTLE_ANIM_FRAMESET_SOUND_3", + "BATTLE_ANIM_FRAMESET_WAVE", + "BATTLE_ANIM_FRAMESET_CONFUSE_RAY_1", + "BATTLE_ANIM_FRAMESET_CONFUSE_RAY_2", + "BATTLE_ANIM_FRAMESET_LEER", + "BATTLE_ANIM_FRAMESET_REFLECT", + "BATTLE_ANIM_FRAMESET_CHICK_1", + "BATTLE_ANIM_FRAMESET_CHICK_2", + "BATTLE_ANIM_FRAMESET_AMNESIA_1", + "BATTLE_ANIM_FRAMESET_AMNESIA_2", + "BATTLE_ANIM_FRAMESET_AMNESIA_3", + "BATTLE_ANIM_FRAMESET_ASLEEP", + "BATTLE_ANIM_FRAMESET_DIG_SAND", + "BATTLE_ANIM_FRAMESET_DIG_PILE", + "BATTLE_ANIM_FRAMESET_SAND", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_1", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_2", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_3", + "BATTLE_ANIM_FRAMESET_PARALYZED", + "BATTLE_ANIM_FRAMESET_PARALYZED_FLIPPED", + "BATTLE_ANIM_FRAMESET_HAZE", + "BATTLE_ANIM_FRAMESET_MIST", + "BATTLE_ANIM_FRAMESET_HORN", + "BATTLE_ANIM_FRAMESET_NEEDLE", + "BATTLE_ANIM_FRAMESET_FLOWER", + "BATTLE_ANIM_FRAMESET_BARRAGE_BALL", + "BATTLE_ANIM_FRAMESET_PAY_DAY", + "BATTLE_ANIM_FRAMESET_HEART", + "BATTLE_ANIM_FRAMESET_SPINNING_BONE", + "BATTLE_ANIM_FRAMESET_STAR", + "BATTLE_ANIM_FRAMESET_SPOON", + "BATTLE_ANIM_FRAMESET_SPARKLE", + "BATTLE_ANIM_FRAMESET_SKY_ATTACK", + "BATTLE_ANIM_FRAMESET_LICK", + "BATTLE_ANIM_FRAMESET_WITHDRAW_SHELL", + "BATTLE_ANIM_FRAMESET_SHRINKING_CHARGE_ORB", + "BATTLE_ANIM_FRAMESET_CONVERSION", + "BATTLE_ANIM_FRAMESET_SWORD", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_1", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_2", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_3", + "BATTLE_ANIM_FRAMESET_SEISMIC_TOSS", + "BATTLE_ANIM_FRAMESET_SHARPEN", + "BATTLE_ANIM_FRAMESET_DEFENSE_CURL", + "BATTLE_ANIM_FRAMESET_METRONOME_HAND", + "BATTLE_ANIM_FRAMESET_AGILITY", + "BATTLE_ANIM_FRAMESET_COTTON", + "BATTLE_ANIM_FRAMESET_MILK_BOTTLE", + "BATTLE_ANIM_FRAMESET_SPIKE", + "BATTLE_ANIM_FRAMESET_ANGER_VEIN", + "BATTLE_ANIM_FRAMESET_HEAL_BELL", + "BATTLE_ANIM_FRAMESET_BATON_PASS", + "BATTLE_ANIM_FRAMESET_LOCK_ON_1", + "BATTLE_ANIM_FRAMESET_LOCK_ON_2", + "BATTLE_ANIM_FRAMESET_LOCK_ON_3", + "BATTLE_ANIM_FRAMESET_LOCK_ON_4", + "BATTLE_ANIM_FRAMESET_MIND_READER_1", + "BATTLE_ANIM_FRAMESET_MIND_READER_2", + "BATTLE_ANIM_FRAMESET_MIND_READER_3", + "BATTLE_ANIM_FRAMESET_MIND_READER_4", + "BATTLE_ANIM_FRAMESET_SAFEGUARD", + "BATTLE_ANIM_FRAMESET_MEGAPHONE_UNUSED", + "BATTLE_ANIM_FRAMESET_ITEM_BAG", + "BATTLE_ANIM_FRAMESET_SPIDER_WEB", + "BATTLE_ANIM_FRAMESET_UNUSED_CAKE", + "BATTLE_ANIM_FRAMESET_IMP", + "BATTLE_ANIM_FRAMESET_IMP_FLIPPED", + "BATTLE_ANIM_FRAMESET_CHERUB", + "BATTLE_ANIM_FRAMESET_PENCIL", + "BATTLE_ANIM_FRAMESET_ENCORE_HAND", + "BATTLE_ANIM_FRAMESET_ENCORE_HAND_FLIPPED", + "BATTLE_ANIM_FRAMESET_DESTINY_BOND", + "BATTLE_ANIM_FRAMESET_MORNING_SUN", + "BATTLE_ANIM_FRAMESET_GLIMMER", + "BATTLE_ANIM_FRAMESET_MOONLIGHT", + "BATTLE_ANIM_FRAMESET_CROSS_CHOP_1", + "BATTLE_ANIM_FRAMESET_CROSS_CHOP_2", + "BATTLE_ANIM_FRAMESET_ZAP_CANNON", + "BATTLE_ANIM_FRAMESET_CURSE_NAIL", + "BATTLE_ANIM_FRAMESET_FORESIGHT_SHINE", + "BATTLE_ANIM_FRAMESET_RAPID_SPIN", + "BATTLE_ANIM_FRAMESET_SWAGGER", + "BATTLE_ANIM_FRAMESET_MEAN_LOOK", + "BATTLE_ANIM_FRAMESET_UPSIDE_DOWN_PUNCH", + "BATTLE_ANIM_FRAMESET_GROWING_SPARKLE", + "BATTLE_ANIM_FRAMESET_RAIN", + "BATTLE_ANIM_FRAMESET_PSYCH_UP", + "BATTLE_ANIM_FRAMESET_SPIRAL_UNUSED", + "BATTLE_ANIM_FRAMESET_AEROBLAST", + "BATTLE_ANIM_FRAMESET_SANDSTORM", + "BATTLE_ANIM_FRAMESET_ENEMYFEET_1ROW", + "BATTLE_ANIM_FRAMESET_PLAYERHEAD_1ROW", + "BATTLE_ANIM_FRAMESET_ENEMYFEET_2ROW", + "BATTLE_ANIM_FRAMESET_PLAYERHEAD_2ROW", +} +M.battleAnimObjectOrder={ + "BATTLE_ANIM_OBJ_HIT_BIG_YFIX", + "BATTLE_ANIM_OBJ_HIT_YFIX", + "BATTLE_ANIM_OBJ_HIT_SMALL_YFIX", + "BATTLE_ANIM_OBJ_HIT_BIG", + "BATTLE_ANIM_OBJ_HIT", + "BATTLE_ANIM_OBJ_HIT_SMALL", + "BATTLE_ANIM_OBJ_PUNCH", + "BATTLE_ANIM_OBJ_KICK", + "BATTLE_ANIM_OBJ_PALM", + "BATTLE_ANIM_OBJ_FANG", + "BATTLE_ANIM_OBJ_PUNCH_SHAKE", + "BATTLE_ANIM_OBJ_EMBER", + "BATTLE_ANIM_OBJ_DRAGON_RAGE", + "BATTLE_ANIM_OBJ_FLAMETHROWER", + "BATTLE_ANIM_OBJ_FIRE_SPIN", + "BATTLE_ANIM_OBJ_FIRE_BLAST", + "BATTLE_ANIM_OBJ_BURNED", + "BATTLE_ANIM_OBJ_BLIZZARD", + "BATTLE_ANIM_OBJ_ICE", + "BATTLE_ANIM_OBJ_ICE_BEAM", + "BATTLE_ANIM_OBJ_RAZOR_LEAF", + "BATTLE_ANIM_OBJ_POKE_BALL", + "BATTLE_ANIM_OBJ_POKE_BALL_BLOCKED", + "BATTLE_ANIM_OBJ_EXPLOSION1", + "BATTLE_ANIM_OBJ_EXPLOSION2", + "BATTLE_ANIM_OBJ_ACID", + "BATTLE_ANIM_OBJ_SLUDGE", + "BATTLE_ANIM_OBJ_BETA_BALL_POOF", + "BATTLE_ANIM_OBJ_BALL_POOF", + "BATTLE_ANIM_OBJ_BIG_ROCK", + "BATTLE_ANIM_OBJ_SMALL_ROCK", + "BATTLE_ANIM_OBJ_STRENGTH", + "BATTLE_ANIM_OBJ_SEISMIC_TOSS", + "BATTLE_ANIM_OBJ_BUBBLE", + "BATTLE_ANIM_OBJ_SURF", + "BATTLE_ANIM_OBJ_SING", + "BATTLE_ANIM_OBJ_WATER_GUN", + "BATTLE_ANIM_OBJ_HYDRO_PUMP", + "BATTLE_ANIM_OBJ_POWDER", + "BATTLE_ANIM_OBJ_BEAM", + "BATTLE_ANIM_OBJ_BEAM_TIP", + "BATTLE_ANIM_OBJ_ICE_BUILDUP", + "BATTLE_ANIM_OBJ_FROZEN", + "BATTLE_ANIM_OBJ_MASTER_BALL_SPARKLE", + "BATTLE_ANIM_OBJ_RECOVER", + "BATTLE_ANIM_OBJ_THUNDER_CENTER", + "BATTLE_ANIM_OBJ_THUNDER_LEFT", + "BATTLE_ANIM_OBJ_THUNDER_RIGHT", + "BATTLE_ANIM_OBJ_THUNDER_WAVE", + "BATTLE_ANIM_OBJ_SPARKS_CIRCLE_BIG", + "BATTLE_ANIM_OBJ_THUNDERBOLT_BALL", + "BATTLE_ANIM_OBJ_SPARKS_CIRCLE", + "BATTLE_ANIM_OBJ_THUNDERSHOCK_BALL", + "BATTLE_ANIM_OBJ_CLAMP", + "BATTLE_ANIM_OBJ_BITE", + "BATTLE_ANIM_OBJ_CUT_DOWN_LEFT", + "BATTLE_ANIM_OBJ_CUT_DOWN_RIGHT", + "BATTLE_ANIM_OBJ_CUT_UP_RIGHT", + "BATTLE_ANIM_OBJ_CUT_LONG_DOWN_LEFT", + "BATTLE_ANIM_OBJ_CUT_LONG_DOWN_RIGHT", + "BATTLE_ANIM_OBJ_SOLAR_BEAM_CHARGE", + "BATTLE_ANIM_OBJ_ABSORB_CENTER", + "BATTLE_ANIM_OBJ_GUST", + "BATTLE_ANIM_OBJ_VINE_WHIP1", + "BATTLE_ANIM_OBJ_VINE_WHIP2", + "BATTLE_ANIM_OBJ_RAZOR_WIND1", + "BATTLE_ANIM_OBJ_RAZOR_WIND2", + "BATTLE_ANIM_OBJ_SONICBOOM_JP", + "BATTLE_ANIM_OBJ_WARP", + "BATTLE_ANIM_OBJ_ABSORB", + "BATTLE_ANIM_OBJ_EGG", + "BATTLE_ANIM_OBJ_FOCUS", + "BATTLE_ANIM_OBJ_BIND1", + "BATTLE_ANIM_OBJ_BIND2", + "BATTLE_ANIM_OBJ_LEECH_SEED", + "BATTLE_ANIM_OBJ_SOUND", + "BATTLE_ANIM_OBJ_WAVE", + "BATTLE_ANIM_OBJ_CONFUSE_RAY", + "BATTLE_ANIM_OBJ_LEER", + "BATTLE_ANIM_OBJ_LEER_TIP", + "BATTLE_ANIM_OBJ_SCREEN", + "BATTLE_ANIM_OBJ_HARDEN", + "BATTLE_ANIM_OBJ_CHICK", + "BATTLE_ANIM_OBJ_AMNESIA", + "BATTLE_ANIM_OBJ_ASLEEP", + "BATTLE_ANIM_OBJ_SKULL", + "BATTLE_ANIM_OBJ_DIG_SAND", + "BATTLE_ANIM_OBJ_DIG_PILE", + "BATTLE_ANIM_OBJ_SAND", + "BATTLE_ANIM_OBJ_PARALYZED", + "BATTLE_ANIM_OBJ_STRING_SHOT", + "BATTLE_ANIM_OBJ_HAZE", + "BATTLE_ANIM_OBJ_MIST", + "BATTLE_ANIM_OBJ_SMOG", + "BATTLE_ANIM_OBJ_POISON_GAS", + "BATTLE_ANIM_OBJ_HORN", + "BATTLE_ANIM_OBJ_NEEDLE", + "BATTLE_ANIM_OBJ_PETAL_DANCE", + "BATTLE_ANIM_OBJ_SLUDGE_BOMB", + "BATTLE_ANIM_OBJ_PAY_DAY", + "BATTLE_ANIM_OBJ_SONICBOOM_JP_UNUSED", + "BATTLE_ANIM_OBJ_MIMIC", + "BATTLE_ANIM_OBJ_ATTRACT", + "BATTLE_ANIM_OBJ_BONEMERANG", + "BATTLE_ANIM_OBJ_BONE_CLUB", + "BATTLE_ANIM_OBJ_BONE_RUSH", + "BATTLE_ANIM_OBJ_SWIFT", + "BATTLE_ANIM_OBJ_KINESIS", + "BATTLE_ANIM_OBJ_FLASH", + "BATTLE_ANIM_OBJ_SHINY", + "BATTLE_ANIM_OBJ_SKY_ATTACK", + "BATTLE_ANIM_OBJ_LICK", + "BATTLE_ANIM_OBJ_WITHDRAW", + "BATTLE_ANIM_OBJ_DRAIN", + "BATTLE_ANIM_OBJ_GROWTH", + "BATTLE_ANIM_OBJ_CONVERSION2", + "BATTLE_ANIM_OBJ_SMOKE", + "BATTLE_ANIM_OBJ_SMOKESCREEN", + "BATTLE_ANIM_OBJ_SWORDS_DANCE", + "BATTLE_ANIM_OBJ_SPEED_LINE", + "BATTLE_ANIM_OBJ_SHARPEN", + "BATTLE_ANIM_OBJ_DEFENSE_CURL", + "BATTLE_ANIM_OBJ_METRONOME_HAND", + "BATTLE_ANIM_OBJ_METRONOME_SPARKLE", + "BATTLE_ANIM_OBJ_DISABLE", + "BATTLE_ANIM_OBJ_AGILITY", + "BATTLE_ANIM_OBJ_HEART", + "BATTLE_ANIM_OBJ_FLAME_WHEEL", + "BATTLE_ANIM_OBJ_SACRED_FIRE", + "BATTLE_ANIM_OBJ_COTTON_SPORE", + "BATTLE_ANIM_OBJ_MILK_DRINK", + "BATTLE_ANIM_OBJ_ANGER", + "BATTLE_ANIM_OBJ_HEAL_BELL", + "BATTLE_ANIM_OBJ_HEAL_BELL_NOTE", + "BATTLE_ANIM_OBJ_BATON_PASS", + "BATTLE_ANIM_OBJ_LOCK_ON", + "BATTLE_ANIM_OBJ_MIND_READER", + "BATTLE_ANIM_OBJ_SAFEGUARD", + "BATTLE_ANIM_OBJ_PROTECT", + "BATTLE_ANIM_OBJ_THIEF", + "BATTLE_ANIM_OBJ_OCTAZOOKA", + "BATTLE_ANIM_OBJ_PRESENT", + "BATTLE_ANIM_OBJ_SPIKES", + "BATTLE_ANIM_OBJ_POWDER_SNOW", + "BATTLE_ANIM_OBJ_DRAGONBREATH", + "BATTLE_ANIM_OBJ_CONVERSION", + "BATTLE_ANIM_OBJ_SPIDER_WEB", + "BATTLE_ANIM_OBJ_CAKE_UNUSED", + "BATTLE_ANIM_OBJ_NIGHTMARE", + "BATTLE_ANIM_OBJ_IN_NIGHTMARE", + "BATTLE_ANIM_OBJ_LOVELY_KISS", + "BATTLE_ANIM_OBJ_SWEET_KISS", + "BATTLE_ANIM_OBJ_SKETCH", + "BATTLE_ANIM_OBJ_ENCORE_HAND", + "BATTLE_ANIM_OBJ_ENCORE_STAR", + "BATTLE_ANIM_OBJ_DESTINY_BOND", + "BATTLE_ANIM_OBJ_MORNING_SUN", + "BATTLE_ANIM_OBJ_GLIMMER", + "BATTLE_ANIM_OBJ_MOONLIGHT", + "BATTLE_ANIM_OBJ_HIDDEN_POWER", + "BATTLE_ANIM_OBJ_CROSS_CHOP1", + "BATTLE_ANIM_OBJ_CROSS_CHOP2", + "BATTLE_ANIM_OBJ_SANDSTORM", + "BATTLE_ANIM_OBJ_ZAP_CANNON", + "BATTLE_ANIM_OBJ_SPITE", + "BATTLE_ANIM_OBJ_CURSE", + "BATTLE_ANIM_OBJ_PERISH_SONG", + "BATTLE_ANIM_OBJ_FORESIGHT", + "BATTLE_ANIM_OBJ_RAPID_SPIN", + "BATTLE_ANIM_OBJ_SWAGGER", + "BATTLE_ANIM_OBJ_BELLY_DRUM_HAND", + "BATTLE_ANIM_OBJ_BELLY_DRUM_NOTE", + "BATTLE_ANIM_OBJ_MEAN_LOOK", + "BATTLE_ANIM_OBJ_BETA_PURSUIT", + "BATTLE_ANIM_OBJ_SHOOTING_SPARKLE", + "BATTLE_ANIM_OBJ_RAIN", + "BATTLE_ANIM_OBJ_B0", + "BATTLE_ANIM_OBJ_PSYCH_UP", + "BATTLE_ANIM_OBJ_ANCIENTPOWER", + "BATTLE_ANIM_OBJ_AEROBLAST", + "BATTLE_ANIM_OBJ_SHADOW_BALL", + "BATTLE_ANIM_OBJ_ROCK_SMASH", + "BATTLE_ANIM_OBJ_FLOWER", + "BATTLE_ANIM_OBJ_COTTON", + "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", + "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW", + "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", + "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW", +} +M.battleAnimFuncOrder={ + "BATTLE_ANIM_FUNC_NULL", + "BATTLE_ANIM_FUNC_USER_TO_TARGET", + "BATTLE_ANIM_FUNC_USER_TO_TARGET_DISAPPEAR", + "BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE", + "BATTLE_ANIM_FUNC_WAVE_TO_TARGET", + "BATTLE_ANIM_FUNC_THROW_TO_TARGET", + "BATTLE_ANIM_FUNC_THROW_TO_TARGET_DISAPPEAR", + "BATTLE_ANIM_FUNC_DROP", + "BATTLE_ANIM_FUNC_USER_TO_TARGET_SPIN", + "BATTLE_ANIM_FUNC_SHAKE", + "BATTLE_ANIM_FUNC_FIRE_BLAST", + "BATTLE_ANIM_FUNC_RAZOR_LEAF", + "BATTLE_ANIM_FUNC_BUBBLE", + "BATTLE_ANIM_FUNC_SURF", + "BATTLE_ANIM_FUNC_SING", + "BATTLE_ANIM_FUNC_WATER_GUN", + "BATTLE_ANIM_FUNC_EMBER", + "BATTLE_ANIM_FUNC_POWDER", + "BATTLE_ANIM_FUNC_POKEBALL", + "BATTLE_ANIM_FUNC_POKEBALL_BLOCKED", + "BATTLE_ANIM_FUNC_RECOVER", + "BATTLE_ANIM_FUNC_THUNDER_WAVE", + "BATTLE_ANIM_FUNC_CLAMP_ENCORE", + "BATTLE_ANIM_FUNC_BITE", + "BATTLE_ANIM_FUNC_SOLAR_BEAM", + "BATTLE_ANIM_FUNC_GUST", + "BATTLE_ANIM_FUNC_RAZOR_WIND", + "BATTLE_ANIM_FUNC_KICK", + "BATTLE_ANIM_FUNC_ABSORB", + "BATTLE_ANIM_FUNC_EGG", + "BATTLE_ANIM_FUNC_MOVE_UP", + "BATTLE_ANIM_FUNC_WRAP", + "BATTLE_ANIM_FUNC_LEECH_SEED", + "BATTLE_ANIM_FUNC_SOUND", + "BATTLE_ANIM_FUNC_CONFUSE_RAY", + "BATTLE_ANIM_FUNC_DIZZY", + "BATTLE_ANIM_FUNC_AMNESIA", + "BATTLE_ANIM_FUNC_FLOAT_UP", + "BATTLE_ANIM_FUNC_DIG", + "BATTLE_ANIM_FUNC_STRING", + "BATTLE_ANIM_FUNC_PARALYZED", + "BATTLE_ANIM_FUNC_SPIRAL_DESCENT", + "BATTLE_ANIM_FUNC_POISON_GAS", + "BATTLE_ANIM_FUNC_HORN", + "BATTLE_ANIM_FUNC_NEEDLE", + "BATTLE_ANIM_FUNC_PETAL_DANCE", + "BATTLE_ANIM_FUNC_THIEF_PAYDAY", + "BATTLE_ANIM_FUNC_ABSORB_CIRCLE", + "BATTLE_ANIM_FUNC_BONEMERANG", + "BATTLE_ANIM_FUNC_SHINY", + "BATTLE_ANIM_FUNC_SKY_ATTACK", + "BATTLE_ANIM_FUNC_GROWTH_SWORDS_DANCE", + "BATTLE_ANIM_FUNC_SMOKE_FLAME_WHEEL", + "BATTLE_ANIM_FUNC_PRESENT_SMOKESCREEN", + "BATTLE_ANIM_FUNC_STRENGTH_SEISMIC_TOSS", + "BATTLE_ANIM_FUNC_SPEED_LINE", + "BATTLE_ANIM_FUNC_SLUDGE", + "BATTLE_ANIM_FUNC_METRONOME_HAND", + "BATTLE_ANIM_FUNC_METRONOME_SPARKLE_SKETCH", + "BATTLE_ANIM_FUNC_AGILITY", + "BATTLE_ANIM_FUNC_SACRED_FIRE", + "BATTLE_ANIM_FUNC_SAFEGUARD_PROTECT", + "BATTLE_ANIM_FUNC_LOCK_ON_MIND_READER", + "BATTLE_ANIM_FUNC_SPIKES", + "BATTLE_ANIM_FUNC_HEAL_BELL_NOTES", + "BATTLE_ANIM_FUNC_BATON_PASS", + "BATTLE_ANIM_FUNC_CONVERSION", + "BATTLE_ANIM_FUNC_ENCORE_BELLY_DRUM", + "BATTLE_ANIM_FUNC_SWAGGER_MORNING_SUN", + "BATTLE_ANIM_FUNC_HIDDEN_POWER", + "BATTLE_ANIM_FUNC_CURSE", + "BATTLE_ANIM_FUNC_PERISH_SONG", + "BATTLE_ANIM_FUNC_RAPID_SPIN", + "BATTLE_ANIM_FUNC_BETA_PURSUIT", + "BATTLE_ANIM_FUNC_RAIN_SANDSTORM", + "BATTLE_ANIM_FUNC_BATTLE_ANIM_OBJ_B0", + "BATTLE_ANIM_FUNC_PSYCH_UP", + "BATTLE_ANIM_FUNC_ANCIENT_POWER", + "BATTLE_ANIM_FUNC_ROCK_SMASH", + "BATTLE_ANIM_FUNC_COTTON", +} +M.battleAnimGfxOrder={ + "BATTLE_ANIM_GFX_NONE", + "BATTLE_ANIM_GFX_HIT", + "BATTLE_ANIM_GFX_CUT", + "BATTLE_ANIM_GFX_FIRE", + "BATTLE_ANIM_GFX_WATER", + "BATTLE_ANIM_GFX_LIGHTNING", + "BATTLE_ANIM_GFX_PLANT", + "BATTLE_ANIM_GFX_SMOKE", + "BATTLE_ANIM_GFX_EXPLOSION", + "BATTLE_ANIM_GFX_ROCKS", + "BATTLE_ANIM_GFX_ICE", + "BATTLE_ANIM_GFX_POKE_BALL", + "BATTLE_ANIM_GFX_POISON", + "BATTLE_ANIM_GFX_BUBBLE", + "BATTLE_ANIM_GFX_NOISE", + "BATTLE_ANIM_GFX_POWDER", + "BATTLE_ANIM_GFX_BEAM", + "BATTLE_ANIM_GFX_SPEED", + "BATTLE_ANIM_GFX_CHARGE", + "BATTLE_ANIM_GFX_WIND", + "BATTLE_ANIM_GFX_WHIP", + "BATTLE_ANIM_GFX_EGG", + "BATTLE_ANIM_GFX_ROPE", + "BATTLE_ANIM_GFX_PSYCHIC", + "BATTLE_ANIM_GFX_REFLECT", + "BATTLE_ANIM_GFX_STATUS", + "BATTLE_ANIM_GFX_SAND", + "BATTLE_ANIM_GFX_WEB", + "BATTLE_ANIM_GFX_HAZE", + "BATTLE_ANIM_GFX_HORN", + "BATTLE_ANIM_GFX_FLOWER", + "BATTLE_ANIM_GFX_MISC", + "BATTLE_ANIM_GFX_SKY_ATTACK", + "BATTLE_ANIM_GFX_GLOBE", + "BATTLE_ANIM_GFX_SHAPES", + "BATTLE_ANIM_GFX_OBJECTS", + "BATTLE_ANIM_GFX_SHINE", + "BATTLE_ANIM_GFX_ANGELS", + "BATTLE_ANIM_GFX_WAVE", + "BATTLE_ANIM_GFX_AEROBLAST", + "BATTLE_ANIM_GFX_PLAYERHEAD", + "BATTLE_ANIM_GFX_ENEMYFEET", +} +M.nativeFunctions={ + ["Null"]="BATTLE_ANIM_FUNC_NULL", + ["MoveFromUserToTarget"]="BATTLE_ANIM_FUNC_USER_TO_TARGET", + ["MoveFromUserToTargetAndDisappear"]="BATTLE_ANIM_FUNC_USER_TO_TARGET_DISAPPEAR", + ["MoveInCircle"]="BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE", + ["MoveWaveToTarget"]="BATTLE_ANIM_FUNC_WAVE_TO_TARGET", + ["ThrowFromUserToTarget"]="BATTLE_ANIM_FUNC_THROW_TO_TARGET", + ["ThrowFromUserToTargetAndDisappear"]="BATTLE_ANIM_FUNC_THROW_TO_TARGET_DISAPPEAR", + ["Drop"]="BATTLE_ANIM_FUNC_DROP", + ["MoveFromUserToTargetSpinAround"]="BATTLE_ANIM_FUNC_USER_TO_TARGET_SPIN", + ["Shake"]="BATTLE_ANIM_FUNC_SHAKE", + ["FireBlast"]="BATTLE_ANIM_FUNC_FIRE_BLAST", + ["RazorLeaf"]="BATTLE_ANIM_FUNC_RAZOR_LEAF", + ["Bubble"]="BATTLE_ANIM_FUNC_BUBBLE", + ["Surf"]="BATTLE_ANIM_FUNC_SURF", + ["Sing"]="BATTLE_ANIM_FUNC_SING", + ["WaterGun"]="BATTLE_ANIM_FUNC_WATER_GUN", + ["Ember"]="BATTLE_ANIM_FUNC_EMBER", + ["Powder"]="BATTLE_ANIM_FUNC_POWDER", + ["PokeBall"]="BATTLE_ANIM_FUNC_POKEBALL", + ["PokeBallBlocked"]="BATTLE_ANIM_FUNC_POKEBALL_BLOCKED", + ["Recover"]="BATTLE_ANIM_FUNC_RECOVER", + ["ThunderWave"]="BATTLE_ANIM_FUNC_THUNDER_WAVE", + ["Clamp_Encore"]="BATTLE_ANIM_FUNC_CLAMP_ENCORE", + ["Bite"]="BATTLE_ANIM_FUNC_BITE", + ["SolarBeam"]="BATTLE_ANIM_FUNC_SOLAR_BEAM", + ["Gust"]="BATTLE_ANIM_FUNC_GUST", + ["RazorWind"]="BATTLE_ANIM_FUNC_RAZOR_WIND", + ["Kick"]="BATTLE_ANIM_FUNC_KICK", + ["Absorb"]="BATTLE_ANIM_FUNC_ABSORB", + ["Egg"]="BATTLE_ANIM_FUNC_EGG", + ["MoveUp"]="BATTLE_ANIM_FUNC_MOVE_UP", + ["Wrap"]="BATTLE_ANIM_FUNC_WRAP", + ["LeechSeed"]="BATTLE_ANIM_FUNC_LEECH_SEED", + ["Sound"]="BATTLE_ANIM_FUNC_SOUND", + ["ConfuseRay"]="BATTLE_ANIM_FUNC_CONFUSE_RAY", + ["Dizzy"]="BATTLE_ANIM_FUNC_DIZZY", + ["Amnesia"]="BATTLE_ANIM_FUNC_AMNESIA", + ["FloatUp"]="BATTLE_ANIM_FUNC_FLOAT_UP", + ["Dig"]="BATTLE_ANIM_FUNC_DIG", + ["String"]="BATTLE_ANIM_FUNC_STRING", + ["Paralyzed"]="BATTLE_ANIM_FUNC_PARALYZED", + ["SpiralDescent"]="BATTLE_ANIM_FUNC_SPIRAL_DESCENT", + ["PoisonGas"]="BATTLE_ANIM_FUNC_POISON_GAS", + ["Horn"]="BATTLE_ANIM_FUNC_HORN", + ["Needle"]="BATTLE_ANIM_FUNC_NEEDLE", + ["PetalDance"]="BATTLE_ANIM_FUNC_PETAL_DANCE", + ["ThiefPayday"]="BATTLE_ANIM_FUNC_THIEF_PAYDAY", + ["AbsorbCircle"]="BATTLE_ANIM_FUNC_ABSORB_CIRCLE", + ["Bonemerang"]="BATTLE_ANIM_FUNC_BONEMERANG", + ["Shiny"]="BATTLE_ANIM_FUNC_SHINY", + ["SkyAttack"]="BATTLE_ANIM_FUNC_SKY_ATTACK", + ["GrowthSwordsDance"]="BATTLE_ANIM_FUNC_GROWTH_SWORDS_DANCE", + ["SmokeFlameWheel"]="BATTLE_ANIM_FUNC_SMOKE_FLAME_WHEEL", + ["PresentSmokescreen"]="BATTLE_ANIM_FUNC_PRESENT_SMOKESCREEN", + ["StrengthSeismicToss"]="BATTLE_ANIM_FUNC_STRENGTH_SEISMIC_TOSS", + ["SpeedLine"]="BATTLE_ANIM_FUNC_SPEED_LINE", + ["Sludge"]="BATTLE_ANIM_FUNC_SLUDGE", + ["MetronomeHand"]="BATTLE_ANIM_FUNC_METRONOME_HAND", + ["MetronomeSparkleSketch"]="BATTLE_ANIM_FUNC_METRONOME_SPARKLE_SKETCH", + ["Agility"]="BATTLE_ANIM_FUNC_AGILITY", + ["SacredFire"]="BATTLE_ANIM_FUNC_SACRED_FIRE", + ["SafeguardProtect"]="BATTLE_ANIM_FUNC_SAFEGUARD_PROTECT", + ["LockOnMindReader"]="BATTLE_ANIM_FUNC_LOCK_ON_MIND_READER", + ["Spikes"]="BATTLE_ANIM_FUNC_SPIKES", + ["HealBellNotes"]="BATTLE_ANIM_FUNC_HEAL_BELL_NOTES", + ["BatonPass"]="BATTLE_ANIM_FUNC_BATON_PASS", + ["Conversion"]="BATTLE_ANIM_FUNC_CONVERSION", + ["EncoreBellyDrum"]="BATTLE_ANIM_FUNC_ENCORE_BELLY_DRUM", + ["SwaggerMorningSun"]="BATTLE_ANIM_FUNC_SWAGGER_MORNING_SUN", + ["HiddenPower"]="BATTLE_ANIM_FUNC_HIDDEN_POWER", + ["Curse"]="BATTLE_ANIM_FUNC_CURSE", + ["PerishSong"]="BATTLE_ANIM_FUNC_PERISH_SONG", + ["RapidSpin"]="BATTLE_ANIM_FUNC_RAPID_SPIN", + ["BetaPursuit"]="BATTLE_ANIM_FUNC_BETA_PURSUIT", + ["RainSandstorm"]="BATTLE_ANIM_FUNC_RAIN_SANDSTORM", + ["AnimObjB0"]="BATTLE_ANIM_FUNC_BATTLE_ANIM_OBJ_B0", + ["PsychUp"]="BATTLE_ANIM_FUNC_PSYCH_UP", + ["AncientPower"]="BATTLE_ANIM_FUNC_ANCIENT_POWER", + ["RockSmash"]="BATTLE_ANIM_FUNC_ROCK_SMASH", + ["Cotton"]="BATTLE_ANIM_FUNC_COTTON", +} +M.battleAnimObPaletteOrder={ + "PAL_BATTLE_OB_ENEMY", + "PAL_BATTLE_OB_PLAYER", + "PAL_BATTLE_OB_GRAY", + "PAL_BATTLE_OB_YELLOW", + "PAL_BATTLE_OB_RED", + "PAL_BATTLE_OB_GREEN", + "PAL_BATTLE_OB_BLUE", + "PAL_BATTLE_OB_BROWN", +} +return M diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index 06d3d7ca5..f3612b662 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -1,181 +1,38 @@ --- Copyright (c) 2026 Cedric. All rights reserved. --- Source-available under the Gen2Recomped License (see LICENSE.md): you may --- read, build and privately modify this file; you may not redistribute it or --- use it commercially. Cartridge-derived data is excluded and is not the --- copyright holder's to license. - --- Gen 2 battle-animation player. --- --- GSC's animations are a bytecode VM, not Gen 1's subanimation/frame-block --- tables, so this is a separate player from src/battle/AnimPlayer.lua rather --- than another code path inside it. The extractor --- (RomExtractorGen2:gen2BattleAnims) hands over: --- --- scripts[animId] = { entry = , code = { , ... } } --- objects[id] = { oamFlags, fixY, frameset, fn, palette, gfx } --- framesets[id] = { frames = { { oam, duration, xflip, yflip } }, --- loop = "hold" | "restart" } --- oam[id] = { tile = , --- sprites = { { y, x, tile, attr } } } --- gfx[id] = { tiles, cols, image } --- palettes[i] = { {r,g,b}, {r,g,b}, {r,g,b}, {r,g,b} } --- moveAnims[MOVE] = animId --- --- The engine's own object `function`s (~200 hand-written asm routines that --- push objects along arcs, spin them, track the target and eventually --- delete them) are not portable as data. Objects here therefore hold the --- position their script spawned them at and simply run their real frameset, --- which reproduces the correct sprites, palettes, placement and timing for --- every move while motion paths read as static bursts. --- --- Coordinates follow the hardware: an object's x/y are OAM coordinates, so --- screen space is (x - 8, y - 16). InitBattleAnimBuffer (33:$49F9) mirrors --- an object for the enemy's side when its oamFlags bit 0 is set: --- x' = 180 - x --- y' = fixY == $FF and 40 + y or fixY - y --- xOffset' = -xOffset - -local BgEffects = require('src.battle.gen2.BgEffects') - -local Gen2AnimPlayer = {} -Gen2AnimPlayer.__index = Gen2AnimPlayer - -local MAX_OBJECTS = 10 -- wActiveAnimObjects holds ten slots -local MAX_STEPS_PER_FRAME = 256 -- guard against a script looping forever -local TAIL_FRAMES = 16 -- let the last objects finish after `ret` -local MAX_OBJECT_AGE = 48 -- see tickObjects -local TRAVEL_FRAMES = 20 -- how long a *ToTarget function takes - --- DoBattleAnimFrame's 80 object functions are hand-written asm, so the port --- reads their names out of the manifest (BATTLE_ANIM_FUNC_*) and sorts them --- into the motion archetypes below. This is an approximation of the paths, --- not a recompilation of them: what it buys is that every object actually --- travels the way its routine travels instead of sitting where the script --- spawned it. Anchors are OAM coordinates: an animation is written from the --- player's side, with the user around (64, 96) and the target pic around --- (136, 48); InitBattleAnimBuffer's mirror maps those to the enemy's side as --- x' = 180 - x and y' = 40 + y. -local TARGET_ANCHOR = { x = 136, y = 48 } -local TARGET_ANCHOR_MIRRORED = { x = 44, y = 88 } -local USER_ANCHOR = { x = 64, y = 96 } -local USER_ANCHOR_MIRRORED = { x = 116, y = 136 } - --- name -> archetype. "gone" suffixes delete the object when it arrives, --- matching the routines whose names end in Disappear. -local MOTION = { - Null = "static", LockOnMindReader = "static", AnimObjB0 = "static", - Sound = "static", - - MoveFromUserToTarget = "toTarget", - MoveFromUserToTargetSpinAround = "toTarget", - MoveFromUserToTargetAndDisappear = "toTargetGone", - Kick = "toTargetGone", SpeedLine = "toTargetGone", - Bite = "toTarget", Horn = "toTarget", Needle = "toTarget", - WaterGun = "toTarget", Surf = "toTarget", String = "toTarget", - SkyAttack = "toTarget", RockSmash = "toTarget", BetaPursuit = "toTarget", - PresentSmokescreen = "toTarget", Clamp_Encore = "toTarget", - Wrap = "toTarget", Dig = "toTarget", - - MoveWaveToTarget = "wave", RazorLeaf = "wave", Gust = "wave", - RazorWind = "wave", PetalDance = "wave", Sing = "wave", - PoisonGas = "wave", Powder = "wave", Cotton = "wave", - - ThrowFromUserToTarget = "arc", PokeBall = "arc", PokeBallBlocked = "arc", - ThrowFromUserToTargetAndDisappear = "arcGone", - Ember = "arc", FireBlast = "arc", SacredFire = "arc", - SmokeFlameWheel = "arc", Sludge = "arc", Egg = "arc", - LeechSeed = "arc", Spikes = "arc", ThiefPayday = "arc", - Bonemerang = "arc", StrengthSeismicToss = "arc", BatonPass = "arc", - - Absorb = "toUser", AbsorbCircle = "circle", Bubble = "toUser", - - MoveInCircle = "circle", ConfuseRay = "circle", Dizzy = "circle", - RapidSpin = "circle", SafeguardProtect = "circle", Conversion = "circle", - HiddenPower = "circle", SolarBeam = "circle", Shiny = "circle", - MetronomeSparkleSketch = "circle", - - MoveUp = "rise", FloatUp = "rise", Recover = "rise", Amnesia = "rise", - GrowthSwordsDance = "rise", HealBellNotes = "rise", PerishSong = "rise", - PsychUp = "rise", AncientPower = "rise", SwaggerMorningSun = "rise", - Agility = "rise", - - Drop = "fall", Curse = "fall", RainSandstorm = "fall", - SpiralDescent = "spiral", - - Shake = "shake", ThunderWave = "shake", Paralyzed = "shake", - EncoreBellyDrum = "shake", MetronomeHand = "shake", -} - -local function shade(value) - return { value, value, value } -end - -local DEFAULT_PALETTE = { - shade(1), shade(0.66), shade(0.33), shade(0), -} - --- Lua 5.1 has no bitwise operators -local function band(a, b) - local result, bit = 0, 1 - for _ = 1, 8 do - if a % 2 == 1 and b % 2 == 1 then result = result + bit end - a, b, bit = math.floor(a / 2), math.floor(b / 2), bit * 2 - end - return result -end - -local function bxor(a, b) - local result, bit = 0, 1 - for _ = 1, 8 do - if a % 2 ~= b % 2 then result = result + bit end - a, b, bit = math.floor(a / 2), math.floor(b / 2), bit * 2 +-- Copyright (c) 2026 Cedric. Existing source rights retained; see NOTICE.md. +-- Native player integration of the Gen1Recomp GSC runtime under GPLv3 plus +-- Additional Terms; compat/gen1-gsc/LICENSE.MD and provenance.json. +-- One real command VM owns BG, ten object structs and forty OAM entries. +local Runtime=require('src.battle.gen2.AnimRunner') +local Data=require('src.battle.Gen2AnimData') +local Gen2AnimPlayer={} +Gen2AnimPlayer.__index=function(self,key) + local method=Gen2AnimPlayer[key] + if method~=nil then return method end + local runner=rawget(self,'runtime') + if runner then + if key=='done'then return runner.stopped end + if key=='frames'or key=='bg'or key=='objects'or key=='keepSprites'then return runner[key]end end - return result end - function Gen2AnimPlayer.new(data) - local anims = data and data.battle_anims or {} + local anims=data and data.battle_anims or {} require('src.battle.Gen2AnimationIds').install(anims) - local self = setmetatable({ - data = data, - anims = anims, - images = {}, - quads = {}, - warned = {}, - objects = {}, - effects = {}, - frames = 0, - bg = BgEffects.new(data and data.constants, {battleTurn=0}), - tail = 0, - -- WideBattle reads these two off the Gen 1 player; keep them defined - steps = {}, - stepIndex = 1, - done = true, - }, Gen2AnimPlayer) - self.view = require('src.battle.Gen2AnimView').new(anims, data) + local tables,constants=Data.tables(anims,data and data.constants) + local runner=Runtime.new({data=tables,constants=constants}) + runner.stopped=true + runner.retire=function()end + local self=setmetatable({data=data,anims=anims,warned={},effects={},runtime=runner},Gen2AnimPlayer) + self.view=require('src.battle.Gen2AnimView').new(tables,data) return self end - function Gen2AnimPlayer:release() - if self.view then self.view:release(); self.view = nil end - for _, img in pairs(self.images) do - if img and img.release then pcall(img.release, img) end - end - self.images, self.quads = {}, {} + if self.view then self.view:release();self.view=nil end end - -function Gen2AnimPlayer:warnOnce(key, fmt, ...) - if self.warned[key] then return end - self.warned[key] = true - print(string.format(fmt, ...)) +function Gen2AnimPlayer:warnOnce(key,fmt,...) + if self.warned[key]then return end + self.warned[key]=true + print(string.format(fmt,...)) end - --- BattleState drives the ball toss and the send-out with Gen 1's animation --- ids (TossBallAnimation's TOSS/POOF/HIDEPIC/SHAKE chain). Gen 2 keeps the --- whole toss in one script -- ANIM_THROW_POKE_BALL reads wPokeBallAnimData --- through anim_checkpokeball and wobbles or breaks out by itself -- so the --- toss row maps onto it and the chain's remaining rows have nothing of their --- own to play. Without this nothing resolved and no ball was ever thrown. local GEN1_BALL_ANIMS = { TOSS_ANIM = "throwPokeBall", GREATTOSS_ANIM = "throwPokeBall", @@ -246,87 +103,94 @@ local function ballParam(self, opts) or DEFAULT_BALL_ITEM end -function Gen2AnimPlayer:start(moveId, attackerIsPlayer, opts) - if self.presentation then self.presentation.retire() end - self.presentation = nil - self.objects = {} - self.effects = {} - self.callStack = {} - self.vars = { var = 0, param = opts and opts.param or 0, loop = nil } - self.wait = 0 - self.tail = 0 - self.ranFrames = 0 - self.frames = 0 - self.wobbles = 0 - self.keepSprites = false - self.keptSprites = nil - -- Gen 1 queues its send-out poof with attackerIsPlayer = false, because - -- TossBallAnimation's flag names the animation's ATTACKER and the poof has - -- to land on the player's own pic (the toss row passes true, for the ball - -- reaching the foe). Gen 2's BattleAnim_SendOutMon is already authored on - -- the sender's side, so reading that flag straight through mirrored the - -- player's own release onto the wild mon. - -- SHINY_ANIM is the same script on the same side as the send-out it - -- follows, so it takes the same flag flip. - if moveId == "POOF_ANIM" or moveId == "SHINY_ANIM" then - attackerIsPlayer = not attackerIsPlayer - end - self.enemySide = not attackerIsPlayer - -- The real command VM owns this pool's clock. Each restart gets a fresh - -- register block, so retired animation observations keep their own state. - self.bg = BgEffects.new(self.data and self.data.constants, { - battleTurn = self.enemySide and 1 or 0, - flying = opts and opts.flying or {}, +function Gen2AnimPlayer:start(moveId,attackerIsPlayer,opts) + if self.presentation then self.presentation.retire()end + self.presentation=nil + self.effects={} + self.keptSprites=nil + self.opts=opts or {} + self.moveId=moveId + if moveId=='POOF_ANIM'or moveId=='SHINY_ANIM'then attackerIsPlayer=not attackerIsPlayer end + self.enemySide=not attackerIsPlayer + self.script=self:scriptFor(moveId) + self.presentationId=moveId + local alias=GEN1_BALL_ANIMS[moveId] + local canonical=alias=='throwPokeBall'and 'ANIM_THROW_POKE_BALL' + or alias=='sendOutMon'and 'ANIM_SEND_OUT_MON' + if self.script and canonical and self:scriptFor(canonical)==self.script then self.presentationId=canonical end + local param=self.opts.param or 0 + if alias=='throwPokeBall'then param=ballParam(self,self.opts) + elseif moveId=='SHINY_ANIM'then param=1 end + local tables,constants=Data.tables(self.anims,self.data and self.data.constants) + local program,entry=Data.program(tables,self.script) + local data,events,options=self.data,self.effects,self.opts + local wobbles=0 + local runner=Runtime.new({data=program,constants=constants, + battleTurn=self.enemySide and 1 or 0,animId=self.presentationId, + param=param,ballPalette=options.ballPalette,sgb=options.sgb, + flying=options.flying, + hooks={ + pokeballWobble=function() + wobbles=wobbles+1 + return wobbles<=(options.shakes or 0)and 0 or options.caught and 1 or 2 + end, + sound=function(_,panning,duration,id) + local Sound=require('src.core.Sound') + local audio=data and data.audio + local name=audio and audio.sfxIndex and audio.sfxIndex[tostring(id)] + if name and audio.sfx and audio.sfx[name]then return Sound.play(data,name)end + local def=data and data.moves and data.moves[moveId] + if def and def.anim then return Sound.playMove(data,def.anim)end + end, + cry=function(side,pitch,length) + events[#events+1]={cry=true,side=side,pitch=pitch,length=length} + end, + }}) + runner:start(entry) + if not self.script then runner.stopped=true end + -- A restarted native player creates a new VM. Old public runners already + -- own their old BG/objects/OAM and have no pointer back to this player. + runner.retire=function()end + self.runtime=runner + self.view.data=program + self.vars=setmetatable({}, { + __index=function(_,key)return runner[key]end, + __newindex=function(_,key,value)runner[key]=value end, }) - self.opts = opts - self.moveId = moveId - self.loops = {} - self.bgSides = {} - self.script = self:scriptFor(moveId) - -- Keep the queue's original ID for ball parameters/side handling, while - -- observers identify the actual GSC program (as Gen1Recomp's runner does). - -- A mod-defined alias is canonical only if it resolves to that same script. - self.presentationId = moveId - local alias = GEN1_BALL_ANIMS[moveId] - local canonical = alias == "throwPokeBall" and "ANIM_THROW_POKE_BALL" - or alias == "sendOutMon" and "ANIM_SEND_OUT_MON" - if self.script and canonical and self:scriptFor(canonical) == self.script then - self.presentationId = canonical + if not self.script and GEN1_BALL_ANIMS[moveId]==nil then + self:warnOnce('anim:'..tostring(moveId),'Gen2AnimPlayer: no animation data for move %s',tostring(moveId)) end - if not self.script then - if GEN1_BALL_ANIMS[moveId] == nil then - self:warnOnce("anim:" .. tostring(moveId), - "Gen2AnimPlayer: no animation data for move %s", - tostring(moveId)) - end - self.done = true - return false - end - if GEN1_BALL_ANIMS[moveId] == "throwPokeBall" then - self.vars.param = ballParam(self, opts) - elseif moveId == "SHINY_ANIM" then - self.vars.param = 1 -- wBattleAnimParam = 1 -> .Shiny - end - -- objects the script addresses by index outlive the port's usual object - -- bound: on hardware they only retire when their asm function says so, - -- and the ball has to sit through four 48-frame wobbles - self.objIndex = 0 - self.scriptObjs = {} - for _, inst in ipairs(self.script.code or {}) do - if inst.op == "incobj" or inst.op == "setobj" then - self.scriptObjs[inst.args[1]] = true - end - end - self.pc = self.script.entry - self.done = false - return true + return self.script~=nil end - -function Gen2AnimPlayer:isDone() - -- BattleAnimRunScript also waits on wNumActiveBattleAnims, but objects - -- only clear themselves from inside their asm functions, so the port ends - -- on the top-level `ret` plus a short drain instead of hanging forever. - return self.done and self.tail <= 0 +function Gen2AnimPlayer:isDone()return self.runtime.stopped end +function Gen2AnimPlayer:pollEffects() + -- The hook holds this same channel for the life of this real runner. + local out={} + for i,event in ipairs(self.effects)do out[i]=event end + for i=#self.effects,1,-1 do self.effects[i]=nil end + return out +end +function Gen2AnimPlayer:update() + if self.runtime.stopped then return false end + local active=self.runtime:step() + if not active and self.runtime.keepSprites then + local kept={} + for i,row in ipairs(self.runtime:oam())do + local copy={};for key,value in pairs(row)do copy[key]=value end + kept[i]=copy + end + kept.runner=self.runtime + self.keptSprites=#kept>0 and kept or nil + end + return active +end +function Gen2AnimPlayer:oam(objects)return objects or self.runtime:oam()end +function Gen2AnimPlayer:finalSprites()return self.keptSprites end +function Gen2AnimPlayer:draw()return self.view:drawObjects(self.runtime,self.battle)end +function Gen2AnimPlayer:drawSprites(objects) + if not objects then return self:draw()end + local runner=setmetatable({oam=function()return objects end},{__index=objects.runner or self.runtime}) + return self.view:drawObjects(runner,self.battle) end -- The historical SE-reset lookup remains available to existing consumers. @@ -363,548 +227,5 @@ Gen2AnimPlayer.BG_EFFECTS_END = BG_EFFECTS_END -- the remaining approximate object runtime is replaced independently. Gen2AnimPlayer.BG_EFFECTS_DROPPED = {} --- BATTLE_ANIM_GFX_PLAYERHEAD / _ENEMYFEET: these two sheets are placeholders --- that BATTLE_BG_EFFECT_BATTLEROBJ_*ROW overwrites with a slice of the --- battler's pic before the object appears. See BG_EFFECTS_DROPPED. -local BATTLER_PIC_GFX = { [0x28] = true, [0x29] = true } - --- BattleState drains this every frame and routes each row through --- applyAnimEffect, the same path Gen 1's player uses. -function Gen2AnimPlayer:pollEffects() - local out = self.effects or {} - self.effects = {} - return out -end - -function Gen2AnimPlayer:spawn(id, x, y, param) - if #self.objects >= MAX_OBJECTS then return end - local def = self.anims.objects and self.anims.objects[id] - if not def then - self:warnOnce("obj:" .. tostring(id), - "Gen2AnimPlayer: unknown animation object %d", id) - return - end - local ox, oy, xOffset = x, y, 0 - if BATTLER_PIC_GFX[def.gfx] then return end - if self.enemySide and (def.oamFlags or 0) % 2 == 1 then - ox = 180 - x - if (def.fixY or 0) == 0xFF then oy = 40 + y else oy = (def.fixY or 0) - y end - xOffset = -xOffset - end - local set = self.anims.framesets and self.anims.framesets[def.frameset] - local first = set and set.frames[1] - if not first then return end - -- BATTLEANIMSTRUCT_INDEX is wLastAnimObjectIndex, bumped once per queued - -- object, and it is what anim_incobj/anim_setobj address. - local index = (self.objIndex or 0) + 1 - self.objIndex = index - self.objects[#self.objects + 1] = { - def = def, x = ox, y = oy, xOffset = xOffset, yOffset = 0, - fn = self.anims.functions and self.anims.functions[def.fn or 0], - param = param, frame = 1, left = first.duration + 1, cycles = 0, age = 0, - index = index, frameset = def.frameset, - -- BATTLEANIMSTRUCT_JUMPTABLE_INDEX / VAR1 / VAR2 - state = 0, var1 = 0, var2 = 0, - persistent = self.scriptObjs and self.scriptObjs[index] or nil, - } -end - -local function frameset(self, obj) - local sets = self.anims.framesets - return sets and sets[obj.frameset or obj.def.frameset] -end - -local function objectByIndex(self, index) - for _, obj in ipairs(self.objects) do - if obj.index == index then return obj end - end - return nil -end - --- ReinitBattleAnimFrameset: point an object at a frameset and restart it -local function reframe(self, obj, framesetId) - local set = self.anims.framesets and self.anims.framesets[framesetId] - local first = set and set.frames[1] - if not first then return end - obj.frameset = framesetId - obj.frame, obj.cycles, obj.age = 1, 0, 0 - obj.left = first.duration + 1 -end - --- BattleAnim_Sine: a = d * sin(a * pi/32), with `a` a wrapping byte angle -local function animSine(a, d) - return math.floor(d * math.sin((a % 64) * math.pi / 32)) -end - --- BATTLE_ANIM_FRAMESET_POKE_BALL_1..5 -local BALL_FRAMESET_1 = 9 -local BALL_FRAMESET_2 = 10 -local BALL_FRAMESET_3 = 11 -local BALL_FRAMESET_4 = 12 -local BALL_FRAMESET_5 = 13 - --- BattleAnimFunc_PokeBall's twelve-state jumptable. anim_setobj/anim_incobj --- only write BATTLEANIMSTRUCT_JUMPTABLE_INDEX; it is each state that picks --- the next frameset, so the port has to run the machine. Treating --- anim_incobj as "next frameset" instead re-ran ReinitBattleAnimFrameset on --- the thrown ball, which reset its travel and made it look like a fresh ball --- was hurled at the mon once per wobble, and left the resting ball stuck on --- POKE_BALL_2 -- the squat 16x8 tile that reads as half a ball. -local function pokeBallStep(self, obj) - local s = obj.state or 0 - if s == 0 then -- init (GetBallAnimPal) - obj.state = 1 - elseif s == 1 then -- BattleAnimFunc_ThrowFromUserToTarget - if obj.x >= 0x88 then - obj.y = obj.y + obj.yOffset - obj.yOffset = 0 - reframe(self, obj, BALL_FRAMESET_3) - obj.state = 2 - return - end - obj.x = obj.x + 2 - obj.y = obj.y - 1 - obj.yOffset = animSine(obj.var1, obj.param or 0) - obj.var1 = (obj.var1 - 1) % 256 - elseif s == 3 then - obj.state = 4 - reframe(self, obj, BALL_FRAMESET_1) - obj.var1, obj.var2 = 0, 0x10 - elseif s == 4 then - obj.yOffset = animSine(obj.var1, obj.var2) - obj.var1 = (obj.var1 - 1) % 256 - if obj.var1 % 32 == 0 then - obj.var1 = 0 - obj.var2 = obj.var2 - 4 - if obj.var2 <= 0 then - reframe(self, obj, BALL_FRAMESET_4) - obj.state = 5 - end - end - elseif s == 6 then - reframe(self, obj, BALL_FRAMESET_5) - obj.state = 5 -- .six falls through to .five after decrementing the index - elseif s == 7 then - reframe(self, obj, BALL_FRAMESET_2) - obj.state = 8 - obj.var2 = 0x20 - elseif s == 8 or s == 10 then - obj.yOffset = animSine(obj.var1, obj.var2) - obj.var1 = (obj.var1 - 1) % 256 - if obj.var1 % 32 == 0 then - obj.state = 11 - elseif obj.var1 % 16 == 0 then - obj.state = s + 1 - end - elseif s == 11 then - obj.dead = true - end - -- .two/.five/.nine simply hold the object where it is -end - -function Gen2AnimPlayer:moveObject(obj) - if obj.fn == "PokeBall" then return pokeBallStep(self, obj) end - local kind = MOTION[obj.fn or "Null"] - if not kind or kind == "static" then return end - local t = math.min(1, obj.age / TRAVEL_FRAMES) - local age = obj.age - - if kind == "circle" then - local angle = age / 6 - obj.xOffset = math.floor(math.cos(angle) * 16 + 0.5) - obj.yOffset = math.floor(math.sin(angle) * 10 + 0.5) - return - elseif kind == "spiral" then - local angle = age / 6 - obj.xOffset = math.floor(math.cos(angle) * (4 + age / 3) + 0.5) - obj.yOffset = math.floor(math.sin(angle) * 6 + 0.5) + age - return - elseif kind == "shake" then - obj.xOffset = (math.floor(age / 2) % 2 == 0) and 1 or -1 - obj.yOffset = 0 - return - elseif kind == "rise" then - obj.xOffset = 0 - obj.yOffset = -age - return - elseif kind == "fall" then - obj.xOffset = 0 - obj.yOffset = age - return - end - - -- everything left travels between the two battlers - local anchor - if kind == "toUser" then - anchor = self.enemySide and USER_ANCHOR_MIRRORED or USER_ANCHOR - else - anchor = self.enemySide and TARGET_ANCHOR_MIRRORED or TARGET_ANCHOR - end - obj.xOffset = math.floor((anchor.x - obj.x) * t + 0.5) - obj.yOffset = math.floor((anchor.y - obj.y) * t + 0.5) - if kind == "arc" or kind == "arcGone" then - obj.yOffset = obj.yOffset - math.floor(math.sin(t * math.pi) * 16 + 0.5) - elseif kind == "wave" then - obj.yOffset = obj.yOffset + math.floor(math.sin(t * math.pi * 4) * 6 + 0.5) - end - if t >= 1 and (kind == "toTargetGone" or kind == "arcGone") then - obj.dead = true - end -end - -function Gen2AnimPlayer:tickObjects() - local live = {} - for _, obj in ipairs(self.objects) do - local set = frameset(self, obj) - if set then - obj.age = obj.age + 1 - self:moveObject(obj) - obj.left = obj.left - 1 - if obj.left <= 0 then - obj.frame = obj.frame + 1 - -- a $FE-terminated frameset restarts; $FF holds the last frame on - -- hardware until the object's asm function deletes it, and with no - -- function to run the object retires instead so the burst clears - if not set.frames[obj.frame] and set.loop == "restart" - and (obj.persistent or obj.cycles < 8) then - obj.frame, obj.cycles = 1, obj.cycles + 1 - elseif not set.frames[obj.frame] and obj.persistent then - -- ...unless the script is still driving this object by index, in - -- which case it really does hold its last frame until told - -- otherwise (the thrown ball waiting out a wobble) - obj.frame = #set.frames - end - local frame = set.frames[obj.frame] - if frame then obj.left = frame.duration + 1 end - end - -- a $FE frameset restarts forever on hardware until the object's own - -- function retires it, so the port bounds the lifetime instead - if set.frames[obj.frame] and not obj.dead - and (obj.persistent or obj.age < MAX_OBJECT_AGE) then - live[#live + 1] = obj - end - end - end - self.objects = live -end - -function Gen2AnimPlayer:step(inst) - local op = inst.op - if op == "delay" then - self.wait = inst.frames - self.pc = self.pc + 1 - return - elseif op == "obj" then - local a = inst.args - self:spawn(a[1], a[2], a[3], a[4]) - elseif op == "clearobjs" then - self.objects = {} - elseif op == "incobj" then - -- BattleAnimCmd_IncObj bumps the addressed object's - -- BATTLEANIMSTRUCT_JUMPTABLE_INDEX, i.e. it advances that object's asm - -- state machine one step; for every object a script drives this way the - -- state's job is to reinit the frameset to the next one in the run - -- (BattleAnimFunction_PokeBall's wobble, Bind, the Amnesia letters). - -- The port has no asm to run, so it advances the frameset directly -- - -- without it the thrown ball never rocked. - local obj = objectByIndex(self, inst.args[1]) - if obj and obj.fn == "PokeBall" then - obj.state = (obj.state or 0) + 1 - elseif obj then - reframe(self, obj, (obj.frameset or obj.def.frameset) + 1) - end - elseif op == "setobj" then - -- the same field, written outright. Only the Poké Ball's jumptable is - -- modelled, so everything else still just restarts its own frameset. - local obj = objectByIndex(self, inst.args[1]) - if obj and obj.fn == "PokeBall" then - obj.state = inst.args[2] or 0 - elseif obj then - reframe(self, obj, obj.def.frameset) - end - elseif op == "bgeffect" then - local id = inst.args[1] - self.bg:queue(id, inst.args[2], inst.args[3], inst.args[4]) - elseif op == "incbgeffect" then - self.bg:incEffect(inst.args[1]) - elseif op == 'bgp' or op == 'obp0' or op == 'obp1' then - self.bg[op] = (inst.args[1] or 0) % 256 - elseif op == 'resetobp0' then - self.bg.obp0 = self.opts and self.opts.sgb and 0xf0 or 0xe0 - elseif op == "checkpokeball" then - -- BattleAnimCmd_CheckPokeball calls GetPokeBallWobble and stores its `c` - -- into wBattleAnimVar, which the toss script then compares against 1 and - -- 2: 0 = wobble once more, 1 = the click (caught), 2 = it broke free. - -- It is read once per wobble, so count them off against the roll - -- BattleState already made and finish on the branch it decided. - self.wobbles = (self.wobbles or 0) + 1 - local opts = self.opts or {} - if self.wobbles <= (opts.shakes or 0) then - self.vars.var = 0 - else - self.vars.var = opts.caught and 1 or 2 - end - elseif op == "keepsprites" then - -- the capture branch ends on anim_keepsprites: the GB never clears the - -- resting closed ball's OAM, so it stays up through the caught text. - -- Snapshot it here rather than at the end of the script -- the object - -- would otherwise retire itself during the drain. - self.keepSprites = true - local kept = {} - for i, obj in ipairs(self.objects) do kept[i] = obj end - self.keptSprites = #kept > 0 and kept or nil - elseif op == "sound" then - self:playSound(inst) - elseif op == "cry" then - self:playCry() - elseif op == "setvar" then - self.vars.var = inst.args[1] - elseif op == "incvar" then - self.vars.var = (self.vars.var + 1) % 256 - elseif op == "jump" then - if inst.to then self.pc = inst.to end - return - elseif op == "call" then - if inst.to then - self.callStack[#self.callStack + 1] = self.pc + 1 - self.pc = inst.to - return - end - elseif op == "ret" then - local back = table.remove(self.callStack) - if back then - self.pc = back - else - self.done = true - self.tail = TAIL_FRAMES - end - return - elseif op == "loop" then - -- anim_loop count, address: the count lives in the instruction, so the - -- player keeps its own counter keyed by the instruction table itself - self.loops = self.loops or {} - local left = self.loops[inst] - if left == nil then left = inst.args[1] end - left = left - 1 - if left > 0 then - self.loops[inst] = left - if inst.to then self.pc = inst.to; return end - else - self.loops[inst] = nil - end - elseif op == "ifvarequal" then - if self.vars.var == inst.args[1] and inst.to then - self.pc = inst.to - return - end - elseif op == "ifparamequal" then - if self.vars.param == inst.args[1] and inst.to then - self.pc = inst.to - return - end - elseif op == "ifparamand" then - if band(self.vars.param, inst.args[1]) ~= 0 and inst.to then - self.pc = inst.to - return - end - elseif op == "jumpuntil" then - -- BattleAnimCmd_JumpUntil is a countdown, not a wait: it jumps while - -- wBattleAnimParam is nonzero and DECREMENTS it on the way, so a script - -- like Fury Cutter's repeats its slash `param` extra times and then - -- falls through. Spinning on the object list instead never terminated - -- (each pass respawns objects), so those moves only ever ended on the - -- runaway bound below -- the "glitchy and far too long" animations. - if self.vars.param ~= 0 and inst.to then - self.vars.param = self.vars.param - 1 - self.pc = inst.to - return - end - end - self.pc = self.pc + 1 -end - -function Gen2AnimPlayer:playSound(inst) - local Sound = require("src.core.Sound") - -- `anim_sound duration, tracks, id` packs duration/tracks into the first - -- byte and the SFX id into the second. That id is the row of the ROM's - -- SFX pointer table, which is exactly how audio.sfxIndex is keyed, so the - -- animation names its own sample -- moves.lua's anim.sound is only the - -- fallback for moves whose script the importer could not walk. - local audio = self.data and self.data.audio - local id = inst and inst.args and inst.args[2] - local name = id and audio and audio.sfxIndex and audio.sfxIndex[tostring(id)] - if name and audio.sfx and audio.sfx[name] then - return Sound.play(self.data, name) - end - local moves = self.data and self.data.moves - local def = moves and self.moveId and moves[self.moveId] - if def and def.anim then - pcall(Sound.playMove, self.data, def.anim) - end -end - -function Gen2AnimPlayer:playCry() - -- anim_cry plays the ATTACKER's cry -- GROWL and ROAR are the two moves - -- that use it (Gen 1 spelled the same rule IsCryMove). - -- - -- This player does not own the battlers, so it cannot name the species - -- itself; queue the request on the very same effect channel every other op - -- reports through and let BattleState, which does know who is attacking, - -- resolve it. Leaving this an empty stub is why Growl fell silent in Gen 2: - -- the "no subanimation player" fallback in BattleState only runs when there - -- is no player at all, and for a Gen 2 move there always is one. - self.effects[#self.effects + 1] = { cry = true } -end - -function Gen2AnimPlayer:update() - self.frames = (self.frames or 0) + 1 - if self.done then - if self.tail > 0 then self.tail = self.tail - 1 end - if self.tail <= 0 and not self.keepSprites then self.objects = {} end - self:tickObjects() - return - end - if self.wait > 0 then - self.wait = self.wait - 1 - self.bg:playFrame() - self:tickObjects() - return - end - local code = self.script.code - self.ranFrames = (self.ranFrames or 0) + 1 - -- a script whose branches depend on engine state the port does not model - -- can still spin; bound it well past the longest real animation - if self.ranFrames > 180 then - self.done = true - self.tail = TAIL_FRAMES - self.bg:playFrame() - self:tickObjects() - return - end - for _ = 1, MAX_STEPS_PER_FRAME do - local inst = code[self.pc] - if not inst then - self.done = true - self.tail = TAIL_FRAMES - break - end - self:step(inst) - if self.done or self.wait > 0 then break end - end - if self.wait > 0 then self.wait = self.wait - 1 end - self.bg:playFrame() - self:tickObjects() -end - -function Gen2AnimPlayer:sheetImage(gfxId) - local cached = self.images[gfxId] - if cached ~= nil then return cached or nil end - local sheet = self.anims.gfx and self.anims.gfx[gfxId] - local img - if sheet and love and love.graphics and love.graphics.newImage then - local ok, loaded = pcall(love.graphics.newImage, sheet.image) - if ok then img = loaded end - end - if not img then - self:warnOnce("gfx:" .. tostring(gfxId), - "Gen2AnimPlayer: anim sheet %s unavailable", - tostring(sheet and sheet.image or gfxId)) - end - self.images[gfxId] = img or false - return img -end - -function Gen2AnimPlayer:tileQuad(gfxId, tile) - local sheet = self.anims.gfx and self.anims.gfx[gfxId] - if not sheet or tile >= sheet.tiles then return nil end - local perSheet = self.quads[gfxId] - if not perSheet then - perSheet = {} - self.quads[gfxId] = perSheet - end - local q = perSheet[tile] - if q == nil and love and love.graphics and love.graphics.newQuad then - local cols = sheet.cols or 16 - local rows = math.ceil(sheet.tiles / cols) - q = love.graphics.newQuad((tile % cols) * 8, math.floor(tile / cols) * 8, - 8, 8, cols * 8, rows * 8) - perSheet[tile] = q - end - return q -end - --- BattleObjectPals holds six palettes that CGBCopyBattleObjectPals loads --- into OBJ slots 2..7; slots 0 and 1 are the two battlers' own palettes, so --- an object using those falls back to the DMG greys. -local OBJ_PALETTE_BASE = 2 - -function Gen2AnimPlayer:paletteFor(index) - self.palCache = self.palCache or {} - local cached = self.palCache[index] - if cached then return cached end - local pals = self.anims.palettes - local pal = pals and pals[index - OBJ_PALETTE_BASE] - if not pal or #pal < 4 then return DEFAULT_PALETTE end - -- the extractor stores BattleObjectPals as 0-255 channels; the shader - -- takes 0-1 triples - local out = {} - for i = 1, 4 do - out[i] = { pal[i][1] / 255, pal[i][2] / 255, pal[i][3] / 255 } - end - self.palCache[index] = out - return out -end - --- Draw the live objects onto the 160x144 battle canvas. Gen 2 is native --- CGB, so the object palettes come straight from BattleObjectPals and --- BattleState's SGB colorFn is ignored. -function Gen2AnimPlayer:draw() - self:drawSprites(self.objects) -end - --- BattleState keeps the resting closed ball on screen after a capture; Gen 2 --- marks it with anim_keepsprites at the end of the .Click branch, so hand --- back whatever OAM the script deliberately left behind. -function Gen2AnimPlayer:finalSprites() - return self.keptSprites -end - --- Project the actual current OAM rows. This is also the input to the native --- object renderer; readers never advance frames or own the VM's objects. -function Gen2AnimPlayer:oam(objects) - local out = {} - for _, obj in ipairs(objects or self.objects) do - local set = frameset(self, obj) - local frame = set and set.frames[obj.frame] - local row = frame and frame.oam and self.anims.oam and self.anims.oam[frame.oam] - if row then - local flip = band(bxor((frame.xflip and 0x20 or 0) - + (frame.yflip and 0x40 or 0), - obj.def.oamFlags or 0), 0xE0) - local xf = math.floor(flip / 32) % 2 == 1 - local yf = math.floor(flip / 64) % 2 == 1 - local palette = obj.def.palette or 0 - if (obj.fn == 'PokeBall' or obj.fn == 'PokeBallBlocked') - and self.opts and self.opts.ballPalette then palette = self.opts.ballPalette end - for _, sprite in ipairs(row.sprites) do - local ox = obj.x + obj.xOffset + (xf and -sprite.x - 8 or sprite.x) - local oy = obj.y + obj.yOffset + (yf and -sprite.y - 8 or sprite.y) - if ox > 0 and ox < 168 and oy > 0 and oy < 160 then - out[#out + 1] = {x=ox, y=oy, tile=row.tile + sprite.tile, - attr=bxor(sprite.attr, flip), palette=palette, gfx=obj.def.gfx} - end - end - end - end - return out -end - -function Gen2AnimPlayer:drawSprites(objects) - local View = require('src.battle.Gen2AnimView') - self.view = self.view or View.new(self.anims, self.data) - self.presentation = self.presentation or View.runner(self) - self.presentation.selectObjects(objects) - return self.view:drawObjects(self.presentation, self.battle) -end return Gen2AnimPlayer diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua index d1a0f7d5c..2225433a6 100644 --- a/src/battle/Gen2AnimView.lua +++ b/src/battle/Gen2AnimView.lua @@ -1,4 +1,4 @@ --- Native object drawing over Gen2AnimPlayer's actual OAM projection. +-- Native object drawing over the genuine GSC VM's emitted OAM. -- Object layout/flip math retains Gen2AnimPlayer provenance (Cedric); -- public owner integration by Roxas2712, see NOTICE.md and contribution licence. -- GSC BG/OBJ register integration follows Gen1Recomp535a5ec0 under @@ -49,7 +49,7 @@ function View:quad(gfx, tile, image) local bySheet = self.quads[gfx] or {}; self.quads[gfx] = bySheet if not bySheet[tile] then local w,h = image:getDimensions() - local cols = sheet.cols or 16 + local cols = sheet.cols or sheet.wide or 16 bySheet[tile] = love.graphics.newQuad((tile%cols)*8, math.floor(tile/cols)*8, 8,8,w,h) end return bySheet[tile] @@ -62,9 +62,17 @@ function View:drawObjects(runner, battle) g.setColor(1,1,1,1) local active,activeByte for _, obj in ipairs(runner:oam()) do - local sheet = self.data.gfx and self.data.gfx[obj.gfx] - local image = sheet and self:image(sheet.image) - local quad = image and self:quad(obj.gfx, obj.tile, image) + local entry,index + for i=#runner.loaded,1,-1 do + local loaded=runner.loaded[i] + if obj.tile>=loaded.tile and obj.tile numeric id, because several functions do frameset ARITHMETIC + -- (`ld a, BATTLE_ANIM_FRAMESET_SOUND_1; add [hl]`) while the extractor + -- writes names. Keeping the struct's FRAMESET_ID numeric is what makes + -- those adds mean the same thing they do on the cart. + self.framesetIds = {} + for index, name in ipairs(self.framesetOrder) do + self.framesetIds[name] = index - 1 + end + -- engine/battle_anims/functions.asm:1158 + self.hram = nil + self.obp0 = nil + return self +end + +function Pool:clear() + for slot = 1, NUM_STRUCTS do self.structs[slot] = newStruct() end + self.lastIndex = 0 + self.oam = {} + self.obp0 = nil +end + +-- BattleAnimCmd_ClearObjs. The cart's loop clears $a0 bytes from +-- wActiveAnimObjects and BATTLEANIMSTRUCT_LENGTH is $18, so it reaches six +-- whole structs plus the first sixteen bytes of the seventh -- enough to zero +-- that one's INDEX, and no further. Structs 8-10 keep running: that is the +-- documented bug (docs/bugs_and_glitches.md), and an animation that spawns +-- more than seven objects visibly depends on it. +function Pool:clearObjs() + for slot = 1, 7 do self.structs[slot] = newStruct() end +end + +function Pool:framesetId(name) + local id = self.framesetIds[name] + if not id then error("unknown battle anim frameset: " .. tostring(name)) end + return id +end + +-- ReinitBattleAnimFrameset: swap framesets and restart the frame walk. +local function reinit(st, framesetId) + st.framesetId = u8(framesetId) + st.duration = 0 + st.frame = 0xff -- `ld [hl], -1` +end + +local function deinit(st) st.index = 0 end + +-- InitBattleAnimation. The object row's six bytes land in the struct in +-- order; the seventh field, TILEID, comes from the tile dict instead +-- (GetBattleAnimTileOffset), because where a sheet ended up in VRAM is a +-- property of the running script and not of the object. +function Pool:queue(objectId, x, y, param, tileOffsetFor) + local name = objectId + if type(objectId) == "number" then + name = self.objectOrder[objectId + 1] or objectId + end + local object = (self.data.objects or {})[name] + if not object then return nil end + for slot = 1, NUM_STRUCTS do + local st = self.structs[slot] + if st.index == 0 then + self.lastIndex = u8(self.lastIndex + 1) + st.index = self.lastIndex + st.oamFlags = object.flags or 0 + st.fixY = object.fixY or 0 + st.framesetId = self.framesetIds[object.frameset] or 0 + st.func = object.func or 0 + st.palette = object.palette or 0 + st.tileId = tileOffsetFor and tileOffsetFor(object.gfx) or 0 + st.x, st.y = u8(x), u8(y) + st.xOffset, st.yOffset = 0, 0 + st.param = u8(param or 0) + st.duration = 0 + st.frame = 0xff + st.jt, st.var1, st.var2 = 0, 0, 0 + st.objectId = name + return st + end + end + -- QueueBattleAnimation returns carry when all ten are busy; the script does + -- not look, and neither does anything here. + return nil +end + +function Pool:findByIndex(value) + for slot = 1, NUM_STRUCTS do + local st = self.structs[slot] + if st.index == value then return st end + end + return nil +end + +function Pool:activeCount() + local count = 0 + for slot = 1, NUM_STRUCTS do + if self.structs[slot].index ~= 0 then count = count + 1 end + end + return count +end + +-------------------------------------------------------------------------- +-- GetBattleAnimFrame (engine/battle_anims/helpers.asm) +-------------------------------------------------------------------------- + +-- What a frameset row yields: the OAM set name, or the "wait" / "delete" +-- pseudo-commands BattleAnimOAMUpdate acts on, plus the frame's flip flags. +local function frameYield(row) + local kind = row[1] + if kind == "wait" then return "wait", 0 end + if kind == "delete" then return "delete", 0 end + return row[2], row[4] or 0 +end + +-- `oamwait n` is not skipped here: GetBattleAnimFrame stores n as the struct's +-- duration and hands the command itself back, so the struct genuinely spends +-- n frames drawing nothing. Only BattleAnimOAMUpdate knows what to do with it. +function Pool:getFrame(st) + local frames = (self.data.framesets or {})[self.framesetOrder[st.framesetId + 1]] + if not frames then return nil, 0 end + for _ = 1, 64 do + if st.duration ~= 0 then + st.duration = st.duration - 1 + local row = frames[st.frame + 1] + if not row then return nil, 0 end + return frameYield(row) + end + st.frame = u8(st.frame + 1) + local row = frames[st.frame + 1] + if not row then return nil, 0 end + local kind = row[1] + if kind == "restart" then + st.duration = 0 + st.frame = 0xff + elseif kind == "end" then + -- Step back two so the next pass lands on the frame before this one and + -- then holds it forever. + st.duration = 0 + st.frame = u8(st.frame - 2) + elseif kind == "delete" then + -- `oamdelete` carries no argument; the cart reads the next byte as a + -- duration anyway and then throws the whole struct away, so it does not + -- matter what lands here. + st.duration = 0 + return "delete", 0 + elseif kind == "wait" then + st.duration = u8(row[2]) + return "wait", 0 + else + st.duration = u8(row[3]) + return frameYield(row) + end + end + error("battle anim frameset never yields a frame: " + .. tostring(self.framesetOrder[st.framesetId + 1])) +end + +-------------------------------------------------------------------------- +-- BattleAnimOAMUpdate (engine/battle_anims/core.asm) +-------------------------------------------------------------------------- + +-- AddOrSubtractY / AddOrSubtractX: a flipped entry mirrors around its own +-- 8-pixel cell, which is `-(offset + 8)`. +local function mirror(value, flip) + if not flip then return value end + return u8(-(u8(value) + 8)) +end + +-- InitBattleAnimBuffer. On the enemy's turn the whole object is reflected +-- onto the other side of the field -- but only if its OAMFLAGS ask for it. +function Pool:initBuffer(st) + local buf = { + oamFlags = bit.band(st.oamFlags, OAM_PRIO), + palette = st.palette, + tileId = st.tileId, + x = st.x, y = st.y, + xOffset = st.xOffset, yOffset = st.yOffset, + } + if (self.env.battleTurn or 0) == 0 then return buf end + buf.oamFlags = st.oamFlags + if bit.band(st.oamFlags, FIX_COORDS) == 0 then return buf end + -- x' = (-10 tiles + 4) - x: reflected about the middle of the field. + buf.x = u8((-10 * 8 + 4) - st.x) + if st.fixY == 0xff then + buf.y = u8(5 * 8 + st.y) + else + local y = u8(st.fixY - st.y) + local animId = self.env.animId + -- The three self-targeting animations whose object sits one tile higher + -- on the enemy's side. + if animId == "KINESIS" or animId == "SOFTBOILED" or animId == "MILK_DRINK" then + y = u8(y - 8) + end + buf.y = y + end + buf.xOffset = u8(-st.xOffset) + return buf +end + +-- One struct's OAM entries appended to self.oam. Returns true once the +-- 40-object shadow OAM is full, which is the carry the caller stops on. +function Pool:updateOam(st) + local buf = self:initBuffer(st) + local oamsetName, frameFlags = self:getFrame(st) + if oamsetName == "wait" or oamsetName == nil then return false end + if oamsetName == "delete" then + deinit(st) + return false + end + buf.oamFlags = bit.band(bit.bxor(frameFlags, buf.oamFlags), OAM_FLAG_MASK) + local set = (self.data.oamsets or {})[oamsetName] + if not set then return false end + local tileId = u8(buf.tileId + (set.vtile or 0)) + local yFlip = bit.band(buf.oamFlags, OAM_YFLIP) ~= 0 + local xFlip = bit.band(buf.oamFlags, OAM_XFLIP) ~= 0 + local oam = self.oam + local pool = self.oamPool + for _, entry in ipairs(set.sprites or EMPTY) do + local n = #oam + if n >= OAM_LIMIT then return true end + -- GetSpriteOAMAttr: the frame's flip/priority flags toggle the entry's; + -- OAM_PAL1 passes through from the entry, and the palette slot comes from + -- the struct. + local attr = bit.band(bit.bxor(entry.attr or 0, buf.oamFlags), OAM_FLAG_MASK) + attr = attr + bit.band(entry.attr or 0, OAM_PAL1) + -- Shadow OAM slots are recycled frame to frame (playFrame), the way the + -- cart rewrites wShadowOAM in place: the 40 tables are made once. + local out = pool and pool[n + 1] + if not out then + out = {} + if pool then pool[n + 1] = out end + end + out.y = u8(buf.y + buf.yOffset + mirror(entry.y or 0, yFlip)) + out.x = u8(buf.x + buf.xOffset + mirror(entry.x or 0, xFlip)) + -- BATTLEANIM_BASE_TILE is added here on the cart and subtracted again by + -- every sheet lookup, so the port keeps tiles in sheet-relative space. + out.tile = u8(tileId + (entry.tile or 0)) + out.attr = attr + out.palette = buf.palette + oam[n + 1] = out + end + return false +end + +-- BattleAnim_UpdateOAM_All: run every live struct's function, then let it +-- write its OAM entries. A struct that deinitialises itself inside its +-- function still draws this frame, because the ASM calls BattleAnimOAMUpdate +-- unconditionally. +-- +-- The OAM list and its entries are reused from frame to frame: what a frame +-- returns is valid until the next playFrame, which is all the draw needs. +function Pool:playFrame() + local oam = self.oamList + if not oam then + oam = {} + self.oamList = oam + self.oamPool = {} + end + for i = #oam, 1, -1 do oam[i] = nil end + self.oam = oam + for slot = 1, NUM_STRUCTS do + local st = self.structs[slot] + if st.index ~= 0 then + local fn = AnimObjects.FUNCTIONS[st.func] + if fn then fn(self, st) end + if self:updateOam(st) then break end + end + end + return self.oam +end + +-------------------------------------------------------------------------- +-- engine/battle_anims/functions.asm +-------------------------------------------------------------------------- + +local function incJt(st) st.jt = u8(st.jt + 1) end + +-- BattleAnim_StepToTarget: inches the object toward the opponent's side, half +-- as far vertically as horizontally. The `dec [hl]` loop runs BEFORE `dec e` +-- is tested, so a vertical step of 0 walks the Y coordinate 256 times -- right +-- back where it started, which is the point. +local function stepToTarget(st, speed) + local e = bit.band(speed, 0xf) + st.x = u8(st.x + e) + local steps = bit.rshift(e, 1) + st.y = u8(st.y - (steps == 0 and 256 or steps)) +end + +-- BattleAnim_StepCircle: circular movement whose height is a quarter of its +-- width. +local function stepCircle(st, angle, radius) + st.yOffset = sra(sra(sine(angle, radius))) + st.xOffset = cosine(angle, radius) +end + +-- A 16-bit accumulator spread over two byte fields, which is how every +-- sub-pixel movement here is done: the HIGH byte is the pixel coordinate and +-- the LOW byte the fraction. +local function add16(high, low, delta) + local value = (u8(high) * 256 + u8(low) + delta) % 0x10000 + return math.floor(value / 256), value % 256 +end + +local F = {} + +F.BATTLE_ANIM_FUNC_NULL = function(_, st) + -- anim_incobj is what walks this one to `.one`, which deletes the object. + if st.jt ~= 0 then deinit(st) end +end + +-- BattleAnimFunction_ThrowFromUserToTarget: right 2 and up 1 a frame, with the +-- object's PARAM as the amplitude of a sine on the Y offset. Returns true for +-- "still going", which is the carry the AndDisappear wrapper reads. +local function throwToTarget(st) + if st.x >= 0x88 then return false end + st.x = u8(st.x + 2) + st.y = u8(st.y - 1) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, st.param) + return true +end + +F.BATTLE_ANIM_FUNC_THROW_TO_TARGET = function(_, st) throwToTarget(st) end + +F.BATTLE_ANIM_FUNC_THROW_TO_TARGET_DISAPPEAR = function(_, st) + if not throwToTarget(st) then deinit(st) end +end + +F.BATTLE_ANIM_FUNC_WAVE_TO_TARGET = function(_, st) + if st.x >= 0x88 then + deinit(st) + return + end + st.x = u8(st.x + 2) + st.y = u8(st.y - 1) + local angle = st.var1 + st.var1 = u8(st.var1 + 4) + st.yOffset = sine(angle, 0x10) + -- The X offset is the cosine divided by sixteen, so the wave is much + -- flatter across than it is up. + st.xOffset = sra(sra(sra(sra(cosine(angle, 0x10))))) +end + +F.BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE = function(_, st) + if st.jt == 0 then + incJt(st) + -- Bit 7 of PARAM starts the object half a turn round; the rest is the + -- radius, so the flag has to come off before it is used as one. + st.var1 = bit.band(st.param, 0x80) ~= 0 and 0x20 or 0 + st.param = bit.band(st.param, 0x7f) + end + local angle = st.var1 + st.yOffset = sine(angle, st.param) + st.xOffset = cosine(angle, st.param) + st.var1 = u8(st.var1 + 1) +end + +F.BATTLE_ANIM_FUNC_USER_TO_TARGET = function(_, st) + if st.jt ~= 0 then + deinit(st) + return + end + if st.x >= 0x84 then return end + stepToTarget(st, st.param) +end + +F.BATTLE_ANIM_FUNC_USER_TO_TARGET_DISAPPEAR = function(_, st) + if st.x >= 0x84 then + deinit(st) + return + end + stepToTarget(st, st.param) +end + +-- GetBallAnimPal: the thrown ball wears the colour of the ball being thrown +-- (data/battle_anims/ball_colors.asm). The battle screen resolves that for +-- wCurItem and hands it over as env.ballPalette. +local function ballPal(self, st) + if self.env.ballPalette then st.palette = self.env.ballPalette end +end + +-- .four: the ball bounces on a shrinking sine while VAR2 steps down by four; +-- when it reaches zero the ball opens. +local function pokeballBounce(self, st) + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 - 1) + if bit.band(st.var1, 0x1f) ~= 0 then return end + -- `ld [hl], a` after the mask: VAR1 is zeroed, not just left on a boundary, + -- so every bounce starts from the same phase. + st.var1 = 0 + st.var2 = u8(st.var2 - 4) + if st.var2 ~= 0 then return end + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_4")) + incJt(st) +end + +-- .eight/.ten: the same sine, but every $10 steps the jumptable advances, so +-- the caught / broke-free branch is only ever reached on a shake boundary. +local function pokeballWobble(_, st) + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 - 1) + if bit.band(st.var1, 0x1f) == 0 then + deinit(st) + return + end + if bit.band(st.var1, 0xf) ~= 0 then return end + incJt(st) +end + +F.BATTLE_ANIM_FUNC_POKEBALL = function(self, st) + local jt = st.jt + if jt == 0 then + ballPal(self, st) + incJt(st) + elseif jt == 1 then + if throwToTarget(st) then return end + -- The arc's Y offset is folded into the coordinate before the ball + -- switches to its opening frameset, so it lands where it fell. + st.y = u8(st.y + st.yOffset) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_3")) + incJt(st) + elseif jt == 3 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_1")) + st.var1, st.var2 = 0, 0x10 + pokeballBounce(self, st) -- .three falls into .four + elseif jt == 4 then + pokeballBounce(self, st) + elseif jt == 6 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_5")) + st.jt = u8(st.jt - 1) + elseif jt == 7 then + ballPal(self, st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_2")) + incJt(st) + st.var2 = 0x20 + pokeballWobble(self, st) -- .seven falls into .eight + elseif jt == 8 or jt == 10 then + pokeballWobble(self, st) + elseif jt == 11 then + deinit(st) + end +end + +local function pokeballBlockedFall(_, st) + if st.y >= 0x80 then + deinit(st) + return + end + st.y = u8(st.y + 4) + st.x = u8(st.x - 2) +end + +F.BATTLE_ANIM_FUNC_POKEBALL_BLOCKED = function(self, st) + local jt = st.jt + if jt == 0 then + ballPal(self, st) + incJt(st) + elseif jt == 1 then + if st.x < 0x70 then + throwToTarget(st) + return + end + incJt(st) + pokeballBlockedFall(self, st) -- .next falls into .two + elseif jt == 2 then + pokeballBlockedFall(self, st) + end +end + +F.BATTLE_ANIM_FUNC_EMBER = function(self, st) + local jt = st.jt + if jt == 0 then + -- The upper nybble of PARAM picks which branch this object runs. + st.jt = bit.band(swap(st.param), 0xf) + elseif jt == 1 then + if st.x >= 0x88 then return end + stepToTarget(st, st.param) + elseif jt == 2 then + deinit(st) + elseif jt == 3 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_FLAMETHROWER")) + end +end + +F.BATTLE_ANIM_FUNC_DROP = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1, st.var2 = 0x30, 0x48 + end + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x3f) ~= 0 then return end + st.var1 = 0x20 + -- Each bounce loses PARAM off the amplitude; once that would go to zero or + -- below, the object is done. + local left = st.var2 - st.param + if left <= 0 then + deinit(st) + return + end + st.var2 = left +end + +F.BATTLE_ANIM_FUNC_USER_TO_TARGET_SPIN = function(_, st) + -- .SetCoords: the lower nybble of PARAM is the horizontal step, half of it + -- the vertical one. + local function setCoords() + local e = bit.band(st.param, 0xf) + st.x = u8(st.x + e) + local steps = bit.rshift(e, 1) + st.y = u8(st.y - (steps == 0 and 256 or steps)) + end + -- .two: a circle whose top is flattened -- the cosine is pulled down by its + -- own radius and halved. + local function orbit() + if st.var1 < 0x40 then + st.yOffset = sra(u8(cosine(st.var1, 0x18) - 0x18)) + st.xOffset = sine(st.var1, 0x18) + st.var1 = u8(st.var1 + bit.band(st.param, 0xf)) + return + end + -- .loop_back: the upper nybble is a lap counter. + local laps = bit.band(st.param, 0xf0) + if laps == 0 then + incJt(st) -- .finish falls into .three + if st.x >= 0xb0 then + deinit(st) + else + setCoords() + end + return + end + st.param = bit.band(st.param, 0xf) + (laps - 0x10) + st.jt = u8(st.jt - 1) + end + local jt = st.jt + if jt == 0 then + if st.x < 0x80 then + setCoords() + return + end + -- .next -> .one -> .two, all on this frame. + incJt(st) + incJt(st) + st.var1 = 0 + orbit() + elseif jt == 1 then + incJt(st) + st.var1 = 0 + orbit() + elseif jt == 2 then + orbit() + elseif jt == 3 then + if st.x >= 0xb0 then + deinit(st) + return + end + setCoords() + end +end + +F.BATTLE_ANIM_FUNC_SHAKE = function(_, st) + -- .done_one: hold for the upper nybble of PARAM, then jump to the other side. + local function flip() + st.var1 = bit.band(swap(st.param), 0xf) + st.xOffset = u8(-st.xOffset) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0 + st.xOffset = bit.band(st.param, 0xf) + flip() -- .zero falls into .one, and VAR1 is 0, so .done_one runs at once + elseif jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + return + end + flip() + elseif jt == 2 then + deinit(st) + end +end + +F.BATTLE_ANIM_FUNC_FIRE_BLAST = function(self, st) + -- .eight: the travelling flame spirals once it arrives. + local function spin() + local angle = st.var1 + st.yOffset = sine(angle, 0x10) + st.xOffset = cosine(angle, 0x10) + st.var1 = u8(st.var1 + 1) + end + -- .seven: straight across, then hand over to the spiral. + local function travel() + if st.x < 0x88 then + st.x = u8(st.x + 2) + st.y = u8(st.y - 1) + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EMBER")) + spin() -- .set_up_eight falls into .eight + end + local jt = st.jt + if jt == 0 then + -- PARAM picks the branch outright: 7 is the flame that travels, and the + -- rest are the five arms of the blast, which only drift. + st.jt = st.param + if st.param == 7 then + travel() + else + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_BURNED")) + end + elseif jt == 1 then + st.yOffset = u8(st.yOffset - 1) + elseif jt == 2 then + st.xOffset = u8(st.xOffset - 1) + elseif jt == 3 then + st.xOffset = u8(st.xOffset + 1) + elseif jt == 4 then + st.yOffset = u8(st.yOffset + 1) + st.xOffset = u8(st.xOffset - 1) + elseif jt == 5 then + st.yOffset = u8(st.yOffset + 1) + st.xOffset = u8(st.xOffset + 1) + elseif jt == 7 then + travel() + elseif jt == 8 then + spin() + elseif jt == 9 then + deinit(st) + end +end + +-- BattleAnim_ScatterHorizontal: a 16-bit per-frame X step picked from the +-- object's PARAM, so a screenful of leaves fans out instead of moving as one. +local function scatterHorizontal(st) + local param = st.param + if bit.band(param, 0x80) == 0 then + if param >= 0x20 then return 0x100 end + if param >= 0x18 then return 0x180 end + return 0x200 + end + local masked = bit.band(param, 0x3f) + if masked >= 0x20 then return -0x100 end + if masked >= 0x18 then return -0x180 end + return -0x200 +end + +F.BATTLE_ANIM_FUNC_RAZOR_LEAF = function(self, st) + local function arcStep() + local radius = bit.band(st.param, 0x3f) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, radius) + st.x, st.var2 = add16(st.x, st.var2, scatterHorizontal(st)) + end + local function arcOrLand() + if st.var1 >= 0x30 then + arcStep() + return + end + incJt(st) + st.var1, st.var2 = 0, 0 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_RAZOR_LEAF_2")) + -- Bit 6 starts the second frameset six frames in, which is the leaf + -- already half-turned. + if bit.band(st.param, 0x40) ~= 0 then st.frame = 5 end + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0x40 + arcOrLand() -- .zero falls into .one + elseif jt == 1 then + arcOrLand() + elseif jt == 2 then + if st.yOffset == 0x20 then + deinit(st) + return + end + st.xOffset = sine(st.var1, 0x10) + if bit.band(st.param, 0x40) ~= 0 then + st.var1 = u8(st.var1 - 1) + else + st.var1 = u8(st.var1 + 1) + end + st.yOffset, st.var2 = add16(st.yOffset, st.var2, 0x80) + elseif jt == 3 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_RAZOR_LEAF_1")) + st.oamFlags = bit.band(st.oamFlags, 0xff - OAM_XFLIP) + incJt(st) + elseif jt >= 4 and jt <= 7 then + incJt(st) + elseif jt == 8 then + if st.x >= 0xc0 then return end + stepToTarget(st, 8) + end +end + +F.BATTLE_ANIM_FUNC_ROCK_SMASH = function(self, st) + if st.jt == 0 then + -- Bit 6 picks between the two rock framesets. + st.framesetId = u8(rlca(rlca(bit.band(st.param, 0x40))) + + self:framesetId("BATTLE_ANIM_FRAMESET_BIG_ROCK")) + incJt(st) + st.var1 = 0x40 + end + if st.var1 < 0x30 then + deinit(st) + return + end + local radius = bit.band(st.param, 0x3f) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, radius) + st.x, st.var2 = add16(st.x, st.var2, scatterHorizontal(st)) +end + +F.BATTLE_ANIM_FUNC_BUBBLE = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0xc + jt = 1 + end + if jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + stepToTarget(st, st.param) + return + end + incJt(st) + st.var1 = 0 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_PULSING_BUBBLE")) + return + end + if jt ~= 2 then return end + if st.x < 0x98 then + st.x, st.var1 = add16(st.x, st.var1, 0x60) + end + if st.y < 0x20 then return end + -- The upper nybble of PARAM is a per-frame rise; `ld d, $ff` is what makes + -- it a NEGATIVE 16-bit step. + st.y, st.var2 = add16(st.y, st.var2, bit.band(st.param, 0xf0) - 0x100) +end + +-- engine/battle_anims/functions.asm:1148 +F.BATTLE_ANIM_FUNC_SURF = function(self, st) + local hram = self.hram + local jt = st.jt + if jt == 0 then + incJt(st) + if hram then + hram.lcdc, hram.lyStart, hram.lyEnd = "SCY", 0x58, 0x5e + end + return + end + if jt == 1 then + if st.y < st.param then + incJt(st) + if hram then hram.lyStart = 0 end + return + end + st.y = u8(st.y - 1) + st.yOffset = sine(st.var1, 0x10) + local top = st.yOffset + st.y - 0x10 + -- `ret c`: the wave stops climbing entirely on the frames the subtraction + -- underflows, offsets and all. + if top < 0 then return end + if hram then hram.lyStart = u8(top) end + st.xOffset = bit.band(st.xOffset + 1, 7) + st.var1 = u8(st.var1 + 2) + return + end + if jt == 3 then + if st.y >= 0x70 then + if hram then + hram.lcdc, hram.lyStart, hram.lyEnd = nil, 0, 0 + end + deinit(st) + return + end + st.y = u8(st.y + 2) + local top = st.y - 0x10 + if top < 0 then return end + if hram then hram.lyStart = u8(top) end + return + end + if jt == 4 then deinit(st) end +end + +F.BATTLE_ANIM_FUNC_SING = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_MUSIC_NOTE_1") + st.param) + end + if st.x >= 0xb8 then + deinit(st) + return + end + stepToTarget(st, 2) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, 8) +end + +F.BATTLE_ANIM_FUNC_WATER_GUN = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + jt = 1 + end + if jt == 1 then + if st.y >= 0x30 then + stepToTarget(st, 2) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, 8) + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_WATER_GUN_2")) + st.yOffset = 0 + st.y = 0x30 + -- Everything but FIX_COORDS is dropped, so the splash never flips. + st.oamFlags = bit.band(st.oamFlags, FIX_COORDS) + jt = 2 + end + if jt ~= 2 then return end + if st.yOffset >= 0x18 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_WATER_GUN_3")) + return + end + st.yOffset = u8(st.yOffset + 1) +end + +F.BATTLE_ANIM_FUNC_POWDER = function(_, st) + if st.yOffset >= 0x38 then + deinit(st) + return + end + st.yOffset, st.var1 = add16(st.yOffset, st.var1, 0x80) + -- Shakes sixteen pixels either side by toggling one bit. + st.xOffset = bit.bxor(st.xOffset, 0x10) +end + +F.BATTLE_ANIM_FUNC_RECOVER = function(_, st) + if st.jt == 0 then + incJt(st) + st.var2 = bit.band(st.param, 0xf0) -- radius + st.var1 = u8(bit.band(st.param, 0xf) * 8) -- starting angle + st.param = 1 -- reused as an every-other-frame toggle + end + if st.var2 == 0 then + deinit(st) + return + end + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.yOffset = sine(angle, st.var2) + st.xOffset = cosine(angle, st.var2) + st.param = bit.bxor(st.param, 1) + if st.param == 0 then return end + st.var2 = u8(st.var2 - 1) +end + +F.BATTLE_ANIM_FUNC_THUNDER_WAVE = function(self, st) + if st.jt == 1 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_THUNDER_WAVE_EXTRA")) + elseif st.jt == 3 then + deinit(st) + end +end + +-- Clamp/Encore: two halves clap together, twice. The frameset the object +-- switches to is the base or the base + 1 (CLAMP_FLIPPED / ENCORE_HAND_ +-- FLIPPED), picked by the SIGN of the sine, so both halves close together. +F.BATTLE_ANIM_FUNC_CLAMP_ENCORE = function(self, st) + local function step() + local value = sine(st.var1, st.param) + st.xOffset = value + reinit(st, bit.band(value, 0x80) ~= 0 and st.var2 or u8(st.var2 + 1)) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x1f) ~= 0 then return end + incJt(st) -- falls into .two, which is a bare IncAnonJumptableIndex + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var2 = st.framesetId + st.var1 = bit.band(st.param, 0x80) ~= 0 and 0x30 or 0x10 + st.param = bit.band(st.param, 0x7f) + step() + elseif jt == 1 then + step() + elseif jt >= 2 and jt <= 5 then + incJt(st) + elseif jt == 6 then + st.jt = 1 + end +end + +F.BATTLE_ANIM_FUNC_BITE = function(self, st) + local function step() + local value = sine(st.var1, st.param) + st.yOffset = value + reinit(st, bit.band(value, 0x80) ~= 0 + and self:framesetId("BATTLE_ANIM_FRAMESET_BITE_1") + or self:framesetId("BATTLE_ANIM_FRAMESET_BITE_2")) + st.var1 = u8(st.var1 + 2) + if bit.band(st.var1, 0x1f) ~= 0 then return end + incJt(st) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = bit.band(st.param, 0x80) ~= 0 and 0x30 or 0x10 + st.param = bit.band(st.param, 0x7f) + step() + elseif jt == 1 then + step() + elseif jt >= 2 and jt <= 5 then + incJt(st) + elseif jt == 6 then + st.jt = 1 + end +end + +F.BATTLE_ANIM_FUNC_SOLAR_BEAM = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1, st.var2 = 0x28, 0 + end + local angle = st.param + st.yOffset = sine(angle, st.var1) + st.xOffset = cosine(angle, st.var1) + if st.var1 == 0 then + deinit(st) + return + end + -- The radius is a 16-bit value shrinking half a pixel a frame. + st.var1, st.var2 = add16(st.var1, st.var2, -0x80) +end + +-- The gust's radius comes from a nine-entry table, so the whirl pulses rather +-- than turning at a constant width. +local GUST_OFFSETS = { [0] = 8, [1] = 6, [2] = 5, [3] = 4, [4] = 5, [5] = 6, + [6] = 8, [7] = 12, [8] = 16 } + +F.BATTLE_ANIM_FUNC_GUST = function(_, st) + local function wobble() + local radius = GUST_OFFSETS[st.var2] or 8 + local angle = st.var1 + -- Height is a sixteenth of the width, plus PARAM's own drift. + st.yOffset = u8(sra(sra(sra(sra(sine(angle, radius))))) + st.param) + st.xOffset = cosine(angle, radius) + st.var1 = u8(st.var1 - 8) + -- PARAM counts DOWN from 0 through $ff; once it drops below $c2 the whirl + -- settles back to the middle. + if st.param ~= 0 and st.param < 0xc2 then + st.var2, st.param, st.xOffset, st.yOffset = 0, 0, 0, 0 + return + end + st.param = u8(st.param - 1) + if bit.band(st.param, 7) ~= 0 then return end + st.var2 = u8(st.var2 + 1) + end + local function move() + wobble() + st.x = u8(st.x + 1) + if bit.band(st.x, 1) ~= 0 then return end + st.y = u8(st.y - 1) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.param = 0 + wobble() -- .zero falls into .one + elseif jt == 1 or jt == 3 then + wobble() + elseif jt == 2 then + if st.x < 0x88 then + move() + else + incJt(st) + end + elseif jt == 4 then + if st.x < 0xb8 then + move() + else + deinit(st) + end + end +end + +F.BATTLE_ANIM_FUNC_ABSORB = function(_, st) + if st.x < 0x30 then + deinit(st) + return + end + local e = bit.band(st.param, 0xf) + st.x = u8(st.x - e) + local steps = bit.rshift(e, 1) + st.y = u8(st.y + (steps == 0 and 256 or steps)) +end + +F.BATTLE_ANIM_FUNC_WRAP = function(_, st) + -- anim_incobj walks the frameset one step along the BIND_1..4 run. + if st.jt ~= 1 then return end + reinit(st, u8(st.framesetId + 1)) + incJt(st) + st.var1 = 8 +end + +-- BattleAnim_StepThrownToTarget: a parabola whose horizontal step is PARAM's +-- two nybbles read as a 16-bit fixed-point number -- the LOW nybble is the +-- fraction and the HIGH nybble the whole pixels, which is the reverse of how +-- the macro's argument reads. +local function stepThrownToTarget(st) + st.var2 = u8(st.var2 - 1) + st.yOffset = sine(st.var2, 0x20) + st.fixY = u8(st.fixY + 2) + local step = bit.rshift(bit.band(st.param, 0xf0), 4) * 256 + + swap(bit.band(st.param, 0xf)) + st.x, st.var1 = add16(st.x, st.var1, step) + if bit.band(st.var2, 1) ~= 0 then return end + st.y = u8(st.y - 1) +end + +F.BATTLE_ANIM_FUNC_LEECH_SEED = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var2 = 0x40 + elseif jt == 1 then + if st.var2 >= 0x20 then + stepThrownToTarget(st) + return + end + st.var2 = 0x40 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_LEECH_SEED_2")) + incJt(st) + elseif jt == 2 then + if st.var2 ~= 0 then + st.var2 = st.var2 - 1 + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_LEECH_SEED_3")) + end +end + +F.BATTLE_ANIM_FUNC_SPIKES = function(_, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var2 = 0x40 + elseif jt == 1 then + if st.var2 >= 0x20 then + stepThrownToTarget(st) + return + end + incJt(st) + end +end + +F.BATTLE_ANIM_FUNC_RAZOR_WIND = function(self, st) + F.BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE(self, st) + -- Fifteen extra steps a frame, so the object races round the circle. + st.var1 = u8(st.var1 + 0xf) +end + +local function kickRoll(_, st) + if st.x >= 0x98 then return end + st.x = u8(st.x + 2) + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.yOffset = sine(angle, 8) +end + +F.BATTLE_ANIM_FUNC_KICK = function(_, st) + local jt = st.jt + if jt == 1 then + if st.y < 0x30 then + st.y = u8(st.y + 4) + return + end + st.jt = 0 + elseif jt == 2 then + if st.x >= 0x98 then return end + st.x = u8(st.x + 2) + -- The kick pins itself to the target's side and holds one frame. + st.oamFlags = bit.bor(st.oamFlags, FIX_COORDS) + st.fixY = 0x90 + st.frame = 0 + st.duration = 2 + st.y = u8(st.y - 1) + elseif jt == 3 then + incJt(st) + st.var1 = 0x2c + st.frame = 0 + st.duration = 0x80 + kickRoll(_, st) -- .three falls into .four + elseif jt == 4 then + kickRoll(_, st) + end +end + +F.BATTLE_ANIM_FUNC_EGG = function(self, st) + -- .EggVerticalWaveMotion, shared by both openings. + local function wave() + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x3f) ~= 0 then return end + st.var1 = 0x20 + st.var2 = u8(st.var2 - 8) + if st.var2 ~= 0 then return end + st.var1, st.var2 = 0, 0 + incJt(st) + end + -- .egg_bomb_step: the egg drifts up half a pixel a frame while it travels. + local function step() + st.x = u8(st.x + 1) + st.y, st.var1 = add16(st.y, st.var1, -0x80) + end + local jt = st.jt + if jt == 0 then + -- The object starts here and then jumps to whichever branch PARAM names, + -- which is how one object serves both Egg Bomb and Softboiled. + st.var1, st.var2 = 0x28, 0x10 + st.jt = st.param + elseif jt == 1 then + if st.x < 0x40 then st.x = u8(st.x + 1) end + wave() + elseif jt == 2 then + if st.x >= 0x88 then + incJt(st) + incJt(st) -- .egg_bomb_done skips straight to .four + return + end + if bit.band(st.x, 0xf) ~= 0 then + step() + return + end + st.var2 = 0x10 + incJt(st) + elseif jt == 3 then + if st.var2 ~= 0 then + st.var2 = st.var2 - 1 + return + end + st.jt = u8(st.jt - 1) + step() + elseif jt == 5 then + deinit(st) + elseif jt == 6 then + if st.x < 0x4b then st.x = u8(st.x + 1) end + wave() + elseif jt == 7 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EGG_WOBBLE")) + incJt(st) + elseif jt == 8 then + local angle = st.var1 + st.var1 = u8(st.var1 + 2) + st.xOffset = sine(angle, 2) + elseif jt == 9 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EGG_CRACKED_BOTTOM")) + st.yOffset = 4 + incJt(st) + elseif jt == 11 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EGG_CRACKED_TOP")) + incJt(st) + st.var1 = 0x40 + elseif jt == 12 then + st.yOffset = sine(st.var1, 0x20) + if st.var1 < 0x30 then + incJt(st) + return + end + st.var1 = u8(st.var1 - 1) + end +end + +F.BATTLE_ANIM_FUNC_MOVE_UP = function(_, st) + -- Runs while the offset is 0 or already past $d8 going negative; anything + -- in between is "far enough up" and ends the object. + if st.yOffset ~= 0 and st.yOffset < 0xd8 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset - st.param) +end + +F.BATTLE_ANIM_FUNC_SOUND = function(self, st) + local function motion() + local angle = st.var2 + st.var2 = u8(st.var2 + 2) + local value = sine(angle, 0x10) + st.xOffset = value + if st.param == 0 then + st.yOffset = u8(-value) + elseif st.param ~= 1 then + st.yOffset = value + end + -- PARAM 1 leaves the Y offset alone: that is the flat sideways wave. + end + if st.jt == 0 then + if (self.env.battleTurn or 0) ~= 0 then + -- `xor $ff; add $3` is 2 - param: the enemy's three angles are the + -- player's three mirrored, 0 <-> 2. + st.param = u8(2 - st.param) + end + incJt(st) + st.var1 = 8 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_SOUND_1") + st.param) + return + end + if st.var1 == 0 then + deinit(st) + return + end + st.var1 = st.var1 - 1 + motion() +end + +F.BATTLE_ANIM_FUNC_CONFUSE_RAY = function(self, st) + if st.jt == 0 then + incJt(st) + st.var2 = bit.band(st.param, 0x3f) + -- Bit 7 becomes both the frameset offset and, once swapped, the radius. + st.param = rlca(bit.band(st.param, 0x80)) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_CONFUSE_RAY_1") + st.param) + return + end + local radius = swap(st.param) + local angle = st.var2 + st.var2 = u8(st.var2 + 1) + st.yOffset = sine(angle, radius) + st.xOffset = cosine(angle, radius) + if st.x >= 0x80 then return end + -- Both tests read the NEW VAR2, and the second `and $1` is applied to what + -- the first `and $3` left, not to the register again. + local phase = bit.band(st.var2, 3) + if phase == 0 then st.y = u8(st.y - 1) end + if bit.band(phase, 1) ~= 0 then return end + st.x = u8(st.x + 1) +end + +F.BATTLE_ANIM_FUNC_DIZZY = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1 = st.framesetId + reinit(st, u8(st.var1 + rlca(bit.band(st.param, 0x80)))) + st.param = bit.band(st.param, 0x7f) + end + local angle = st.param + st.yOffset = sra(sra(sine(angle, 0x10))) + st.xOffset = cosine(angle, 0x10) + -- `ld a, [hl]` then `inc [hl]`: the frameset flip tests the PRE-increment + -- angle, so the two chick frames swap on the same beat as the circle. + st.param = u8(st.param + 1) + local phase = bit.band(angle, 0x3f) + if phase == 0 then + reinit(st, st.var1) + elseif bit.band(phase, 0x1f) == 0 then + reinit(st, u8(st.var1 + 1)) + end +end + +-- Hardcoded Y offsets, one per PARAM. +local AMNESIA_OFFSETS = { [0] = 0xec, [1] = 0xf8, [2] = 0x00 } + +F.BATTLE_ANIM_FUNC_AMNESIA = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_AMNESIA_1") + st.param) + st.yOffset = AMNESIA_OFFSETS[st.param] or 0 + elseif st.jt == 2 then + -- anim_incobj forces the object to deinit; Present is what uses it. + deinit(st) + end +end + +F.BATTLE_ANIM_FUNC_FLOAT_UP = function(_, st) + local angle = st.var1 + st.var1 = u8(st.var1 + 2) + st.xOffset = sine(angle, 4) + -- `lb hl, -1, $a0` is the 16-bit constant $ffa0: up 3/8 of a pixel a frame. + st.yOffset, st.var2 = add16(st.yOffset, st.var2, -0x60) +end + +F.BATTLE_ANIM_FUNC_DIG = function(_, st) + local angle = st.var1 + st.var1 = u8(st.var1 - 2) + st.yOffset = sine(angle, 0x10) + st.x = u8(st.x + 1) +end + +F.BATTLE_ANIM_FUNC_STRING = function(self, st) + if st.jt ~= 0 then return end + incJt(st) + -- PARAM 0 is the one that flips on the enemy's turn. + if st.param == 0 then st.oamFlags = bit.bor(st.oamFlags, OAM_YFLIP) end + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_STRING_SHOT_1") + st.param) +end + +F.BATTLE_ANIM_FUNC_PARALYZED = function(self, st) + if st.jt == 0 then + incJt(st) + st.var1 = 0 + local param = st.param + -- Bits 4-6 become the hold time; bit 7 flips the object, and the low + -- nybble is how far it jitters. + st.param = bit.band(swap(bit.band(param, 0x70)), 0xf) + if bit.band(param, 0x80) == 0 then + st.xOffset = bit.band(param, 0xf) + else + st.xOffset = u8(-bit.band(param, 0xf)) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_PARALYZED_FLIPPED")) + end + return + end + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + return + end + st.var1 = st.param + st.xOffset = u8(-st.xOffset) +end + +-- A shared descent: a circle whose height is an eighth of its width, sinking +-- one pixel every few frames until it is $28 down. Spiral Descent checks +-- every eight frames, Petal Dance every four. +local function spiralDescent(st, mask) + local angle = st.var1 + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.xOffset = cosine(angle, 0x18) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, mask) ~= 0 then return end + if st.var2 >= 0x28 then + deinit(st) + return + end + st.var2 = u8(st.var2 + 1) +end + +F.BATTLE_ANIM_FUNC_SPIRAL_DESCENT = function(_, st) spiralDescent(st, 7) end +F.BATTLE_ANIM_FUNC_PETAL_DANCE = function(_, st) spiralDescent(st, 3) end + +F.BATTLE_ANIM_FUNC_POISON_GAS = function(_, st) + if st.jt ~= 0 then + spiralDescent(st, 7) + return + end + if st.x >= 0x84 then + incJt(st) + return + end + st.x = u8(st.x + 1) + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.xOffset = cosine(angle, 0x18) + if bit.band(st.x, 1) ~= 0 then return end + st.y = u8(st.y - 1) +end + +F.BATTLE_ANIM_FUNC_SMOKE_FLAME_WHEEL = function(_, st) + local angle = st.param + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.xOffset = cosine(angle, 0x18) + st.param = u8(st.param + 2) + if bit.band(st.param, 7) ~= 0 then return end + if st.var2 == 0xe8 then + deinit(st) + return + end + st.var2 = u8(st.var2 - 1) +end + +F.BATTLE_ANIM_FUNC_SACRED_FIRE = function(_, st) + local angle = st.param + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.xOffset = cosine(angle, 0x18) + st.param = u8(st.param + 2) + if bit.band(st.param, 3) ~= 0 then return end + if st.var2 == 0xd0 then + deinit(st) + return + end + st.var2 = u8(st.var2 - 2) +end + +F.BATTLE_ANIM_FUNC_PRESENT_SMOKESCREEN = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1, st.var2 = 0x34, 0x10 + elseif st.jt == 2 then + deinit(st) + return + end + if st.jt ~= 1 then return end + if st.x >= 0x6c then return end + stepToTarget(st, 2) + local value = sine(st.var1, st.var2) + -- Only the upper half of the bounce shows: a positive sine is negated, so + -- the puff always sits above its line. + if bit.band(value, 0x80) == 0 then value = u8(-value) end + st.yOffset = value + st.var1 = u8(st.var1 - 4) + -- The halving below is unreachable on the cart: `and $1f` can never leave + -- $20, so Present's puff keeps its height the whole way across. +end + +F.BATTLE_ANIM_FUNC_HORN = function(_, st) + local function spin() + local value = sine(st.var2, 8) + st.xOffset = value + st.y = u8(st.var1 - sra(value)) + st.var2 = u8(st.var2 + 8) + end + local jt = st.jt + if jt == 0 then + st.jt = st.param + st.var1 = st.y + elseif jt == 1 then + if st.x >= 0x58 then return end + stepToTarget(st, 2) + elseif jt == 2 then + if st.var2 >= 0x20 then + deinit(st) + return + end + spin() + elseif jt == 3 then + spin() + end +end + +F.BATTLE_ANIM_FUNC_NEEDLE = function(_, st) + local function line() + if st.x >= 0x84 then + deinit(st) + return + end + stepToTarget(st, st.param) + end + local jt = st.jt + if jt == 0 then + -- The upper nybble of PARAM picks straight line or arc. + st.jt = bit.band(swap(st.param), 0xf) + elseif jt == 1 then + line() + elseif jt == 2 then + local value = sine(st.var1, 0x10) + -- Only the negative half is written, so the needle arcs upward only. + if bit.band(value, 0x80) ~= 0 then st.yOffset = value end + st.var1 = u8(st.var1 - 4) + line() -- .two falls into .one + end +end + +F.BATTLE_ANIM_FUNC_THIEF_PAYDAY = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1 = 0x28 + st.var2 = u8(st.y - 0x28) + end + st.yOffset = sine(st.var1, st.var2) + -- PARAM is a MASK, so the coin only drifts left on the frames that clear it. + if bit.band(st.var1, st.param) == 0 then st.x = u8(st.x - 1) end + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x3f) ~= 0 then return end + st.var1 = 0x20 + st.var2 = bit.rshift(st.var2, 1) +end + +F.BATTLE_ANIM_FUNC_ABSORB_CIRCLE = function(_, st) + local angle = st.param + st.yOffset = sine(angle, st.var1) + st.xOffset = cosine(angle, st.var1) + st.param = u8(st.param + 1) + if bit.band(st.param, 1) == 0 then st.x = u8(st.x - 1) end + if bit.band(st.param, 3) == 0 then st.y = u8(st.y + 1) end + if st.x >= 0x5a then + st.var1 = u8(st.var1 + 1) + return + end + if st.var1 == 0 then + deinit(st) + return + end + st.var1 = u8(st.var1 - 1) +end + +F.BATTLE_ANIM_FUNC_CONVERSION = function(_, st) + local angle = st.param + st.param = u8(st.param + 1) + st.yOffset = sine(angle, st.var1) + st.xOffset = cosine(angle, st.var1) + local age = st.var2 + st.var2 = u8(st.var2 + 1) + if age < 0x40 then + st.var1 = u8(st.var1 + 1) + return + end + local radius = st.var1 + st.var1 = u8(st.var1 - 1) + if radius ~= 0 then return end + deinit(st) +end + +F.BATTLE_ANIM_FUNC_BONEMERANG = function(_, st) + if st.jt == 0 then + incJt(st) + st.var2 = st.y + end + st.y = u8(st.var2 + sine(st.param, 0x30)) + -- Eight steps of phase between the two axes is what bends the throw into a + -- boomerang instead of a circle. + st.xOffset = cosine(st.param + 8, 0x30) + st.param = u8(st.param + 1) +end + +F.BATTLE_ANIM_FUNC_SHINY = function(_, st) + if st.jt ~= 0 then return end + incJt(st) + st.yOffset = sine(st.param, 0x10) + st.xOffset = cosine(st.param, 0x10) + st.var2 = 0xf +end + +-- Sky Attack pulses OBP0 rather than moving anything, so the palette write is +-- what the view has to see. +local SKY_ATTACK_GBC = { [0] = 0xff, [1] = 0xaa, [2] = 0x55, [3] = 0xaa } +local SKY_ATTACK_SGB = { [0] = 0xff, [1] = 0xff, [2] = 0x00, [3] = 0x00 } + +F.BATTLE_ANIM_FUNC_SKY_ATTACK = function(self, st) + local function cyclePalette() + local phase = bit.band(st.var2, 7) + st.var2 = u8(st.var2 + 1) + local pals = self.env.sgb and SKY_ATTACK_SGB or SKY_ATTACK_GBC + self.obp0 = bit.band(pals[bit.rshift(phase, 1)] or 0xff, st.var1) + if self.hram then self.hram.obp0 = self.obp0 end + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = (self.env.battleTurn or 0) == 0 and 0xf0 or 0xcc + elseif jt == 1 then + cyclePalette() + elseif jt == 2 then + cyclePalette() + if st.x >= 0x84 then return end + stepToTarget(st, 4) + elseif jt == 3 then + cyclePalette() + if st.x >= 0xd0 then + deinit(st) + return + end + stepToTarget(st, 4) + end +end + +F.BATTLE_ANIM_FUNC_GROWTH_SWORDS_DANCE = function(_, st) + local angle = st.param + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.param = u8(st.param + 1) + st.xOffset = cosine(angle, 0x18) + st.var2 = u8(st.var2 - 2) +end + +F.BATTLE_ANIM_FUNC_STRENGTH_SEISMIC_TOSS = function(_, st) + local jt = st.jt + if jt == 0 then + if st.yOffset == 0xe0 then + incJt(st) + st.var1 = 2 + return + end + st.yOffset, st.var1 = add16(st.yOffset, st.var1, -0x80) + elseif jt == 1 then + if st.var2 ~= 0 then + st.var2 = st.var2 - 1 + return + end + -- Shakes by negating the accumulated step and folding it into the offset + -- every four frames. + st.var2 = 4 + st.var1 = u8(-st.var1) + st.yOffset = u8(st.yOffset + st.var1) + elseif jt == 2 then + if st.x >= 0x84 then + deinit(st) + return + end + stepToTarget(st, 4) + end +end + +F.BATTLE_ANIM_FUNC_SPEED_LINE = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_SPEED_LINE_1") + + bit.band(st.param, 0x7f)) + end + if bit.band(st.param, 0x80) ~= 0 then + st.xOffset = u8(st.xOffset - 1) + else + st.xOffset = u8(st.xOffset + 1) + end +end + +F.BATTLE_ANIM_FUNC_SLUDGE = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0xc + elseif jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_SLUDGE_BUBBLE_BURST")) + st.yOffset = u8(st.yOffset - 1) -- .done falls into .two + elseif jt == 2 then + st.yOffset = u8(st.yOffset - 1) + end +end + +F.BATTLE_ANIM_FUNC_METRONOME_HAND = function(_, st) + local angle = st.var1 + st.var1 = u8(st.var1 + 2) + st.yOffset = sine(angle, 2) + st.xOffset = cosine(angle, 8) +end + +F.BATTLE_ANIM_FUNC_METRONOME_SPARKLE_SKETCH = function(_, st) + if st.yOffset >= 0x20 then + deinit(st) + return + end + st.xOffset = cosine(st.param, 8) + st.param = u8(st.param + 2) + if bit.band(st.param, 7) ~= 0 then return end + st.yOffset = u8(st.yOffset + 1) +end + +F.BATTLE_ANIM_FUNC_AGILITY = function(_, st) + -- anim_incobj is what makes it disappear. + if st.jt ~= 0 then + deinit(st) + return + end + st.x = u8(st.x + st.param) +end + +F.BATTLE_ANIM_FUNC_SAFEGUARD_PROTECT = function(_, st) + local angle = st.param + st.yOffset = sine(angle, 0x18) + st.xOffset = sra(cosine(angle, 0x18)) + st.param = u8(st.param + 1) +end + +F.BATTLE_ANIM_FUNC_LOCK_ON_MIND_READER = function(_, st) + -- .two: hold for $10 frames, then go. + local function hold() + local left = st.var1 + st.var1 = u8(st.var1 - 1) + if left ~= 0 then return end + deinit(st) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0x28 + -- The low nybble walks along the four LOCK_ON / MIND_READER framesets, + -- and the object's own frameset id is the base of that run. + reinit(st, u8(st.framesetId + bit.band(st.param, 0xf))) + st.param = bit.bor(bit.band(st.param, 0xf0), 8) + jt = 1 + end + if jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + -- The radius is the distance still to run, so the ring converges. + local radius = u8(st.var1 + 8) + st.yOffset = sine(st.param, radius) + st.xOffset = cosine(st.param, radius) + return + end + st.var1 = 0x10 + incJt(st) + hold() -- .done falls into .two + return + end + if jt == 2 then hold() end +end + +F.BATTLE_ANIM_FUNC_HEAL_BELL_NOTES = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_MUSIC_NOTE_1") + st.param) + end + if st.yOffset >= 0x38 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset + 1) + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.xOffset = cosine(angle, 0x18) + -- Tests the Y COORDINATE, not the counter: the note drifts left on the + -- frames its row happens to be even. + if bit.band(st.y, 1) ~= 0 then return end + st.x = u8(st.x - 1) +end + +F.BATTLE_ANIM_FUNC_BATON_PASS = function(_, st) + if st.param == 0 then return end + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + local value = sine(angle, st.param) + if bit.band(value, 0x80) == 0 then value = u8(-value) end + st.yOffset = value + if bit.band(st.var1, 0x1f) ~= 0 then return end + -- Each bounce is half the last. + st.param = bit.rshift(st.param, 1) +end + +F.BATTLE_ANIM_FUNC_ENCORE_BELLY_DRUM = function(_, st) + if st.var1 >= 0x10 then + deinit(st) + return + end + local radius = st.var1 + st.var1 = u8(st.var1 + 2) + st.yOffset = sine(st.param, radius) + st.xOffset = cosine(st.param, radius) +end + +F.BATTLE_ANIM_FUNC_SWAGGER_MORNING_SUN = function(_, st) + -- The top two bits of PARAM are the speed and the low six the angle; the + -- amplitude is VAR1 as it was BEFORE this frame's speed was added. + local radius = st.var1 + st.var1 = u8(st.var1 + rlca(rlca(bit.band(st.param, 0xc0)))) + local angle = bit.band(st.param, 0x3f) + st.yOffset = sine(angle, radius) + st.xOffset = cosine(angle, radius) +end + +F.BATTLE_ANIM_FUNC_HIDDEN_POWER = function(_, st) + -- .two: the ring expands eight pixels a frame and then vanishes. + local function expand() + if st.var1 >= 0x80 then + deinit(st) + return + end + local radius = st.var1 + st.var1 = u8(st.var1 + 8) + stepCircle(st, st.param, radius) + end + local jt = st.jt + if jt == 0 then + local angle = st.param + st.param = u8(st.param + 1) + stepCircle(st, angle, 0x18) + elseif jt == 1 then + incJt(st) + st.var1 = 0x18 + expand() -- .one falls into .two + elseif jt == 2 then + expand() + end +end + +F.BATTLE_ANIM_FUNC_CURSE = function(_, st) + if st.jt ~= 1 then return end + if st.x < 0x30 then + deinit(st) + return + end + st.x = u8(st.x - 2) + st.y = u8(st.y + 2) +end + +F.BATTLE_ANIM_FUNC_PERISH_SONG = function(_, st) + local angle = st.param + st.param = u8(st.param + 2) + -- VAR1 is both the sink and the counter: the sine is added to it and then + -- it is stepped, so the ring drifts down as it turns. + st.yOffset = u8(sra(sra(sine(angle, 0x50))) + st.var1) + st.var1 = u8(st.var1 + 1) + st.xOffset = cosine(angle, 0x50) +end + +F.BATTLE_ANIM_FUNC_RAPID_SPIN = function(_, st) + if st.yOffset == 0xd0 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset - 4) +end + +F.BATTLE_ANIM_FUNC_BETA_PURSUIT = function(_, st) + local jt = st.jt + if jt == 0 then + if st.param ~= 0 then + incJt(st) + incJt(st) + return + end + incJt(st) + st.yOffset = 0xec + elseif jt == 1 then + if st.yOffset == 4 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset + 4) + elseif jt == 2 then + if st.yOffset == 0xd8 then return end + st.yOffset = u8(st.yOffset - 4) + elseif jt == 3 then + deinit(st) + end +end + +F.BATTLE_ANIM_FUNC_RAIN_SANDSTORM = function(_, st) + -- The Y offset wraps at $70, which is what makes a single object read as a + -- continuous fall of rain. + local function fall(step) + local y = st.yOffset + 4 + st.yOffset = (y < 0x70) and y or 0 + st.xOffset = u8(st.xOffset + step) + end + local jt = st.jt + if jt == 0 then + st.jt = u8(st.param + 1) -- .zero sets the index from PARAM and then incs + elseif jt == 1 then + fall(2) + elseif jt == 2 then + fall(8) + elseif jt == 3 then + fall(4) + end +end + +F.BATTLE_ANIM_FUNC_BATTLE_ANIM_OBJ_B0 = function(_, st) + -- Unused on the cart (nothing names BATTLE_ANIM_OBJ_B0), transcribed + -- because the jumptable slot is real: PARAM's nybbles become a 16-bit step + -- over XCOORD:VAR1, the high nybble duplicated into both halves of the + -- whole-pixel byte. + local high = bit.band(st.param, 0xf0) + high = bit.bor(high, bit.rshift(high, 4)) + st.x, st.var1 = add16(st.x, st.var1, + high * 256 + swap(bit.band(st.param, 0xf))) +end + +F.BATTLE_ANIM_FUNC_PSYCH_UP = function(_, st) + local angle = st.param + st.param = u8(st.param + 1) + stepCircle(st, angle, 0x18) +end + +F.BATTLE_ANIM_FUNC_COTTON = function(_, st) + local phase = st.var2 + st.var2 = u8(st.var2 + 1) + stepCircle(st, u8(bit.rshift(phase, 1) + st.param), 0x18) +end + +F.BATTLE_ANIM_FUNC_ANCIENT_POWER = function(_, st) + if st.var1 >= 0x20 then + deinit(st) + return + end + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.yOffset = u8(-sine(angle, st.param)) +end + +AnimObjects.FUNCTIONS = F +AnimObjects.NUM_STRUCTS = NUM_STRUCTS +AnimObjects.OAM_LIMIT = OAM_LIMIT +AnimObjects.OAM_XFLIP = OAM_XFLIP +AnimObjects.OAM_YFLIP = OAM_YFLIP +AnimObjects.OAM_PRIO = OAM_PRIO +AnimObjects.OAM_PAL1 = OAM_PAL1 +AnimObjects.FIX_COORDS = FIX_COORDS +AnimObjects.sine, AnimObjects.cosine = sine, cosine +AnimObjects.u8, AnimObjects.s8, AnimObjects.sra = u8, s8, sra +AnimObjects.swap, AnimObjects.rlca = swap, rlca + +return AnimObjects diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua new file mode 100644 index 000000000..cc2719412 --- /dev/null +++ b/src/battle/gen2/AnimRunner.lua @@ -0,0 +1,485 @@ +-- Gen1Recomp-derived runtime: GPLv3 plus Additional Terms. +-- See compat/gen1-gsc/LICENSE.MD and pinned provenance.json. +-- The Gen 2 battle-animation command interpreter. +-- +-- pokegold engine/battle_anims/anim_commands.asm: RunBattleAnimScript's frame +-- loop and the 48-entry BattleAnimCommands jumptable it dispatches through. +-- The scripts themselves are already disassembled into the cache by +-- RomExtractorGen2 (`data/generated/battle_anims.lua`), keyed by their ROM +-- address because that is what a branch names. +-- +-- One frame of an animation is exactly three things, in this order: +-- +-- RunBattleAnimCommand run script bytes until one asks to wait +-- ExecuteBGEffects one pass over the five BG-effect structs +-- BattleAnim_UpdateOAM_All one pass over the ten object structs +-- +-- so `step()` here is one 60 Hz frame and nothing else needs a clock. The +-- animation is over when a `ret` runs outside a subroutine, which is what +-- BATTLEANIM_STOP_F means. +-- +-- Two things about the script format that are easy to get wrong and are +-- already handled by the extractor, repeated here because this is where they +-- bite: anything under $d0 is `anim_wait ` and carries no arguments, and a +-- branch's target is the LAST two bytes of the command, which the extractor +-- has already rewritten into a pool key. +-- +-- Love-free: sound and cries go out through the `hooks` table the battle +-- screen supplies, so a test can step a whole animation and assert what it +-- asked to play. + +local bit = require("bit") +local AnimObjects = require("src.battle.gen2.AnimObjects") +local BgEffects = require("src.battle.gen2.BgEffects") + +local AnimRunner = {} +local Runner = {} +Runner.__index = Runner + +-- wBattleAnimTileDict is five {gfx id, tile id} pairs. +local NUM_TILEDICT_ENTRIES = 5 +-- BATTLEANIM_BASE_TILE is 7*7; the sheets share the tiles above it up to +-- vTiles1, so the running allocator stops at 128 - 49. +local MAX_ANIM_TILES = 128 - 49 + +-- BattleAnimCmd_BattlerGFX_*: the battlers' own pic tiles are registered in +-- the dict at fixed ids rather than loaded from AnimObjGFX. These are the +-- ASM's `($80 - 6 - 7) - BATTLEANIM_BASE_TILE` and friends. +local BATTLER_TILES = { + oneRow = { player = (0x80 - 6 - 7) - 49, enemy = (0x80 - 6) - 49 }, + twoRow = { player = (0x80 - 6 * 2 - 7 * 2) - 49, enemy = (0x80 - 6 * 2) - 49 }, +} + +-- BattleAnimCmd_Cry's .CryData: a pitch and a length added to the mon's own +-- cry, indexed by the command's argument masked to NUM_NOISE_CHANS. +local CRY_DATA = { + [0] = { pitch = 0x0000, length = 0x00c0 }, + [1] = { pitch = 0x0000, length = 0x0040 }, + [2] = { pitch = 0x0000, length = 0x0000 }, + [3] = { pitch = 0x0000, length = 0x0000 }, +} + +-- BattleAnimCmd_Sound's .GetPanning, indexed by the cry-track pair. +local PANNING = { [0] = 0xf0, [1] = 0x0f, [2] = 0xf0, [3] = 0x0f } + +-------------------------------------------------------------------------- + +-- opts: +-- data the cache's battle_anims.lua +-- constants the cache's constants.lua +-- battleTurn hBattleTurn -- 0 while the player is attacking +-- param wBattleAnimParam, which the effect layer sets (hit count, +-- stat direction, the Beat Up party slot...) +-- animId the move (or ANIM_* id) whose script this is +-- hooks { sound(name, panning, duration), cry(side, pitch, length) } +-- ballPalette the PAL_BATTLE_OB_* name for the ball being thrown +function AnimRunner.new(opts) + opts = opts or {} + local self = setmetatable({}, Runner) + self.data = opts.data or {} + self.constants = opts.constants or {} + self.hooks = opts.hooks or {} + -- Shared by both pools; the object functions and the BG effects read the + -- same hBattleTurn. + self.env = { + battleTurn = opts.battleTurn or 0, + animId = opts.animId, + ballPalette = opts.ballPalette, + sgb = opts.sgb, + flying = opts.flying or {}, + } + self.objects = AnimObjects.new(self.data, self.constants, self.env) + self.bg = BgEffects.new(self.constants, self.env) + -- engine/battle_anims/functions.asm:1158 + self.objects.hram = self.bg + self.animId = opts.animId -- wFXAnimID + self.gfxOrder = self.constants.battleAnimGfxOrder or {} + self.sfxOrder = opts.sfxOrder or {} + + self.param = opts.param or 0 -- wBattleAnimParam + self.var = 0 -- wBattleAnimVar + self.delay = 0 -- wBattleAnimDelay + self.loops = 0 -- wBattleAnimLoops + self.inSubroutine = false + self.inLoop = false + self.stopped = false + self.keepSprites = false + self.frames = 0 + -- wBattleAnimTileDict, and the sheets it points at. + self.tileDict = {} + self.loaded = {} + -- Set by the substitute / minimize / transform commands, for the view. + self.picOverride = { player = nil, enemy = nil } + self.address = nil + self.parent = nil + return self +end + +-- ClearBattleAnims: the whole animation block, then the entry point. +function Runner:start(scriptKey) + self.objects:clear() + self.bg:reset() + self.var, self.delay, self.loops = 0, 0, 0 + self.inSubroutine, self.inLoop, self.stopped = false, false, false + self.keepSprites = false + self.frames = 0 + self.tileDict = {} + self.loaded = {} + self.picOverride = { player = nil, enemy = nil } + self.address = scriptKey and { key = scriptKey, index = 1 } or nil + self.parent = nil + return self +end + +-- The script for a move, or nil when the cache has none (which is what an +-- unextracted or modded move looks like). +function AnimRunner.scriptForMove(data, moveId) + local moves = (data or {}).moves or {} + return moves[moveId] +end + +function Runner:scriptRows(key) + return (self.data.scripts or {})[key] +end + +-- GetBattleAnimByte, one decoded row at a time. +function Runner:fetch() + local at = self.address + if not at then return nil end + local rows = self:scriptRows(at.key) + if not rows then return nil end + local row = rows[at.index] + if not row then return nil end + at.index = at.index + 1 + return row +end + +-- The three "skip the branch target" tails: a conditional that does not take +-- its branch steps the address past the two address bytes, which in a decoded +-- row list is simply "carry on". +function Runner:jumpTo(key) + self.address = { key = key, index = 1 } +end + +-------------------------------------------------------------------------- +-- The tile dict +-------------------------------------------------------------------------- + +-- GetBattleAnimTileOffset: the dict is scanned for the gfx id and its tile +-- returned; a miss is 0, which is why an object whose sheet the script never +-- loaded draws whatever happens to sit at the base tile. +function Runner:tileOffsetFor(gfxId) + for i = 1, NUM_TILEDICT_ENTRIES do + local entry = self.tileDict[i] + if entry and entry.gfx == gfxId then return entry.tile end + end + return 0 +end + +-- BattleAnimCmd_1GFX..5GFX. The running tile id restarts at 0 for every +-- command and each sheet is laid down after the last, so two animations that +-- load different sheet counts do not agree about where anything is -- which +-- is exactly why the dict exists. Entries past the count are NOT cleared. +function Runner:loadGfx(names) + local tile = 0 + for slot, gfxId in ipairs(names) do + if tile >= MAX_ANIM_TILES then break end + local name = gfxId + if type(gfxId) == "number" then + name = self.gfxOrder[gfxId + 1] or gfxId + end + self.tileDict[slot] = { gfx = name, tile = tile } + local sheet = (self.data.gfx or {})[name] + self.loaded[#self.loaded + 1] = { + gfx = name, tile = tile, tiles = (sheet and sheet.tiles) or 0, + } + tile = tile + ((sheet and sheet.tiles) or 0) + end +end + +-- engine/battle_anims/anim_commands.asm:755. The jumptable crosses the macro +-- names: $d9 (anim_battlergfx_2row) dispatches to _1Row (#1401) +function Runner:loadBattlerGfx(rows) + local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow + local slot = 1 + while slot <= NUM_TILEDICT_ENTRIES and self.tileDict[slot] do + slot = slot + 1 + end + if slot + 1 > NUM_TILEDICT_ENTRIES then return end + self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player } + self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy } + self.loaded[#self.loaded + 1] = + { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 7, + battler = "enemy", rows = rows } + self.loaded[#self.loaded + 1] = + { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 6, + battler = "player", rows = rows } +end + +-------------------------------------------------------------------------- +-- BattleAnimCommands +-------------------------------------------------------------------------- + +local C = {} + +C.obj = function(self, row) + self.objects:queue(row[2], row[3], row[4], row[5], function(gfx) + return self:tileOffsetFor(gfx) + end) +end + +for count = 1, 5 do + C[count .. "gfx"] = function(self, row) + local names = {} + for i = 1, count do names[i] = row[i + 1] end + self:loadGfx(names) + end +end + +C.incobj = function(self, row) + local st = self.objects:findByIndex(row[2]) + if st then st.jt = AnimObjects.u8(st.jt + 1) end +end + +C.setobj = function(self, row) + local st = self.objects:findByIndex(row[2]) + if st then st.jt = AnimObjects.u8(row[3]) end +end + +C.incbgeffect = function(self, row) self.bg:incEffect(row[2]) end + +-- anim_commands.asm:317: $d9 (anim_battlergfx_2row) dispatches to _1Row +C.battlergfx_1row = function(self) self:loadBattlerGfx(2) end +C.battlergfx_2row = function(self) self:loadBattlerGfx(1) end + +-- GetPokeBallWobble's answer, which the ball's own script then branches on. +C.checkpokeball = function(self) + self.var = self.hooks.pokeballWobble and self.hooks.pokeballWobble() or 0 +end + +-- The commands that swap a battler's pic out for something else. The port +-- records which, and the view draws it. +-- +-- Every one of them branches on hBattleTurn the same way +-- (engine/battle_anims/anim_commands.asm): `and a / jr z, .player`, and the +-- .player arm is the one that writes vTiles2 tile $31, the 6x6 BACKPIC slot. +-- So turn 0, the player attacking, always repaints the PLAYER's own pic, and +-- the fall-through arm (tile $00, the 7x7 frontpic) repaints the enemy's. +C.transform = function(self) + -- BattleAnimCmd_Transform: .player loads wTempEnemyMonSpecies into the + -- backpic slot, i.e. the player's sprite becomes what it transformed into. + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = "transform" +end + +C.raisesub = function(self) + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = "substitute" +end + +C.dropsub = function(self) + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = false +end + +-- BattleAnimCmd_MinimizeOpp / GetMinimizePic: despite the name it shrinks the +-- ATTACKER, because .player (turn 0) requests the 6x6 block at tile $31. The +-- other minimize opcode, $e9, is one of the dummies below. +C.minimizeopp = function(self) + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = "minimize" +end + +C.beatup = function(self) + -- wBattleAnimParam is the party slot whose pic to show. + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = { kind = "beatup", slot = self.param } +end + +C.resetobp0 = function(self) + self.bg.obp0 = self.env.sgb and 0xf0 or 0xe0 +end + +C.sound = function(self, row) + local packed = row[2] or 0 + -- The first byte is BOTH the duration (its top six bits) and the cry-track + -- pair (its bottom two), which is why the same value reads twice here. + local duration = bit.rshift(packed, 2) + local tracks = bit.band(packed, 3) + if self.env.battleTurn ~= 0 then tracks = bit.bxor(tracks, 1) end + local id = row[3] or 0 + local name = self.sfxOrder[id + 1] + if self.hooks.sound then + self.hooks.sound(name, PANNING[tracks] or 0xff, duration, id) + end +end + +C.cry = function(self, row) + local slot = bit.band(row[2] or 0, 3) + local entry = CRY_DATA[slot] or CRY_DATA[0] + local side = self.env.battleTurn == 0 and "player" or "enemy" + if self.hooks.cry then self.hooks.cry(side, entry.pitch, entry.length) end +end + +C.clearobjs = function(self) self.objects:clearObjs() end + +C.oamon = function() end +C.oamoff = function() end +C.updateactorpic = function() end +-- $e7 and $e8-$ed are `ret` on the cart; $f5-$f7 too. $e9 is `minimize`, and +-- it really is one of them: BattleAnimCmd_E8 through BattleAnimCmd_ED are six +-- labels stacked on a single `ret` (engine/battle_anims/anim_commands.asm). +-- The minimize animation that is actually drawn is $e2, minimizeopp above. +C.minimize = function() end +C.unknown_e7 = function() end +C.unknown_ea = function() end +C.unknown_eb = function() end +C.unknown_ec = function() end +C.unknown_ed = function() end +C.unknown_f5 = function() end +C.unknown_f6 = function() end +C.unknown_f7 = function() end + +C.keepsprites = function(self) self.keepSprites = true end + +C.bgp = function(self, row) self.bg.bgp = row[2] end +C.obp0 = function(self, row) self.bg.obp0 = row[2] end +C.obp1 = function(self, row) self.bg.obp1 = row[2] end + +C.bgeffect = function(self, row) + self.bg:queue(row[2], row[3], row[4], row[5]) +end + +C.setvar = function(self, row) self.var = AnimObjects.u8(row[2]) end +C.incvar = function(self) self.var = AnimObjects.u8(self.var + 1) end + +C.if_var_equal = function(self, row) + if row[2] == self.var then self:jumpTo(row[3]) end +end + +C.if_param_equal = function(self, row) + if row[2] == self.param then self:jumpTo(row[3]) end +end + +C.if_param_and = function(self, row) + if bit.band(self.param, row[2] or 0) ~= 0 then self:jumpTo(row[3]) end +end + +-- The one conditional that CONSUMES what it tests: each pass decrements +-- wBattleAnimParam, so `anim_jumpuntil` runs its block param times. +C.jumpuntil = function(self, row) + if self.param == 0 then return end + self.param = AnimObjects.u8(self.param - 1) + self:jumpTo(row[2]) +end + +C.jump = function(self, row) self:jumpTo(row[2]) end + +C.loop = function(self, row) + local count = row[2] or 0 + if not self.inLoop then + -- A count of 0 loops forever and never claims the loop flag. + if count ~= 0 then + self.inLoop = true + self.loops = AnimObjects.u8(count - 1) + end + self:jumpTo(row[3]) + return + end + if self.loops == 0 then + self.inLoop = false + return -- falls through past the target + end + self.loops = self.loops - 1 + self:jumpTo(row[3]) +end + +C.call = function(self, row) + self.parent = { key = self.address.key, index = self.address.index } + self.inSubroutine = true + self:jumpTo(row[2]) +end + +C.ret = function(self) + self.inSubroutine = false + self.address = self.parent + and { key = self.parent.key, index = self.parent.index } or nil +end + +-------------------------------------------------------------------------- + +-- RunBattleAnimCommand: burn the delay, otherwise run script rows until one +-- of them asks to wait or the animation ends. +function Runner:runCommands() + if self.delay ~= 0 then + self.delay = self.delay - 1 + return + end + for _ = 1, 512 do + local row = self:fetch() + if not row then + self.stopped = true + return + end + local cmd = row[1] + if cmd == "ret" then + -- A `ret` outside a subroutine is what ends the whole animation. + if not self.inSubroutine then + self.stopped = true + return + end + C.ret(self, row) + elseif cmd == "wait" then + self.delay = row[2] or 0 + return + else + local fn = C[cmd] + if fn then fn(self, row) end + end + end + -- A script that never waits would hang the battle; stopping is the only + -- honest thing to do with one. + self.stopped = true +end + +-- One frame. Returns false once the animation is over. +function Runner:step() + if self.stopped then return false end + self.frames = self.frames + 1 + self:runCommands() + self.bg:playFrame() + -- A BG effect can ask for an object (the battler-pic ones do), and it has + -- to land before the object pass or it would be a frame late. + for _, spawn in ipairs(self.bg:takeSpawns()) do + self.objects:queue(spawn.object, spawn.x, spawn.y, spawn.param, + function(gfx) return self:tileOffsetFor(gfx) end) + end + self.objects:playFrame() + -- Rollout hands the shake to the first object's Y offset. + if self.bg.rolloutYOffset then + local first = self.objects.structs[1] + if first and first.index ~= 0 then first.yOffset = self.bg.rolloutYOffset end + end + if self.stopped then + -- engine/battle_anims/anim_commands.asm:213 + if not self.keepSprites then + self.objects.oam = {} + else + for _, obj in ipairs(self.objects.oam) do + obj.palette = "PAL_BATTLE_OB_ENEMY" + end + end + return false + end + return true +end + +function Runner:oam() return self.objects.oam end +function Runner:done() return self.stopped end + +AnimRunner.COMMANDS = C +AnimRunner.NUM_TILEDICT_ENTRIES = NUM_TILEDICT_ENTRIES +AnimRunner.MAX_ANIM_TILES = MAX_ANIM_TILES +AnimRunner.BATTLER_TILES = BATTLER_TILES + +return AnimRunner diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 487c5add3..5f0cce32a 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -14787,11 +14787,13 @@ GEN2_ANIM.BY_HANDLER = { IncObj = { "incobj", 1 }, SetObj = { "setobj", 2 }, IncBGEffect = { "incbgeffect", 1 }, - BattlerGFX_1Row = { "battlergfx_1row" }, - BattlerGFX_2Row = { "battlergfx_2row" }, + -- Macro names cross the cartridge handler names: D9's 2row macro + -- dispatches to _1Row. Preserve the decoded macro contract. + BattlerGFX_1Row = { "battlergfx_2row" }, + BattlerGFX_2Row = { "battlergfx_1row" }, -- Prism's names for the same two slots - EnemyFeetObj = { "battlergfx_1row" }, - PlayerHeadObj = { "battlergfx_2row" }, + EnemyFeetObj = { "battlergfx_2row" }, + PlayerHeadObj = { "battlergfx_1row" }, CheckPokeball = { "checkpokeball" }, Transform = { "transform" }, RaiseSub = { "raisesub" }, @@ -15110,7 +15112,7 @@ end -- Name each object `function` slot from DoBattleAnimFrame's jumptable. The -- routines themselves are hand-written asm and cannot be ported as data, but --- the names tell the player which motion to approximate. +-- the names resolve the imported IDs to the genuine GSC object routines. function RomExtractorGen2:gen2AnimFunctions() local out = {} -- Resolved from the routine that dispatches through it, never hardcoded: @@ -15163,6 +15165,7 @@ function RomExtractorGen2:gen2BattleAnims() end return { gen2 = true, + commandFormat = "gsc-macro-v1", scripts = scripts, moveAnims = moveAnims, objects = self:gen2AnimObjects(), diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 975e9022b..8bbfb1772 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1323,6 +1323,10 @@ local function markerFor(version) if version == "red" or version == "blue" or version == "yellow" then revision = "gen1-trade-art-v1:" end + -- Reimport generated animation commands; keep saves and unrelated caches. + if GameVersion.generation(version)==2 then + revision=revision..'gen2-anim-object-v'..require('src.battle.Gen2AnimData').CACHE_REVISION..':' + end return CACHE_FORMAT .. revision .. GameVersion.info(version).sha1 end diff --git a/tests/engine/gen2_animation_identity.lua b/tests/engine/gen2_animation_identity.lua index c8aa0519d..ff6ad4d09 100644 --- a/tests/engine/gen2_animation_identity.lua +++ b/tests/engine/gen2_animation_identity.lua @@ -27,15 +27,17 @@ for _,edition in ipairs({'gold','silver','crystal'})do end T.eq(r.frames,p.frames,'repeated scalar reads observe native time') T.eq(projections,0,'scalar queries never project OAM') - T.eq(#r.objects.structs,0,'object inspection remains separate from OAM projection') + T.eq(#r.objects.structs,10,'real object pool retains all ten hardware slots') + T.eq(r.objects:activeCount(),0,'idle hardware slots are empty') + T.eq(r,p.runtime,'public runner is the actual native interpreter') T.eq(projections,0,'object metadata queries never project OAM') - r:oam();T.eq(projections,1,'explicit sprite drawing projects actual OAM once') + r:oam();T.eq(projections,0,'real OAM is already emitted by the VM, with no draw-time projection') local before=p.frames;p:update();T.eq(r.frames,p.frames,'live frame observation advances with VM') T.eq(p.frames,before+1,'observation does not advance the clock') p:start('FIX_MOVE',false) T.eq(r.animId,expected,'retired observation preserves original resolved program') - T.eq(projections,2,'retiring captures OAM only once') - r.retire();T.eq(projections,2,'repeated retirement does not rebuild sprites') + T.eq(projections,0,'old runner keeps its own OAM without retirement projection') + r.retire();T.eq(projections,0,'repeated retirement does not rebuild sprites') end local p=Player.new(data) data.battle_anims.moveAnims.TOSS_ANIM=1 diff --git a/tests/engine/gen2_animation_objects.lua b/tests/engine/gen2_animation_objects.lua index e314757a9..1c492296f 100644 --- a/tests/engine/gen2_animation_objects.lua +++ b/tests/engine/gen2_animation_objects.lua @@ -8,7 +8,7 @@ local Wide=require('src.battle.WideBattle') local oldVersion=Version.get() local function fixture() return {battle_anims={gen2=true,moveAnims={FIX_MOVE=1},misc={throwPokeBall=1}, - scripts={[1]={entry=1,code={{op='obj',args={0,40,40,0}},{op='delay',frames=5},{op='ret'}}}}, + scripts={[1]={entry=1,code={{op='gfx',ids={2}},{op='obj',args={0,40,40,0}},{op='delay',frames=5},{op='ret'}}}}, objects={[0]={oamFlags=0,fixY=255,frameset=0,fn=0,palette=4,gfx=2}},functions={[0]='Null'}, framesets={[0]={frames={{oam=0,duration=20}},loop='hold'}}, oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0},{x=8,y=0,tile=1,attr=32}}}}, @@ -22,40 +22,36 @@ for _,edition in ipairs({'gold','silver','crystal'})do T.eq(p:start('FIX_MOVE',true,{param=17}),true,'native bytecode starts normally') T.eq(p.vars.param,17,'native script receives its caller parameter') p:update() - local native=p.objects[1] + local native=p.objects.structs[1] local rows=p:oam() T.eq(#rows,2,'actual frame OAM contains both object tiles') - T.same(rows[1],{x=40,y=40,tile=0,attr=0,palette=4,gfx=2},'first native sprite projection') - T.same(rows[2],{x=48,y=40,tile=1,attr=32,palette=4,gfx=2},'second native sprite retains tile and flip') + T.same(rows[1],{x=40,y=40,tile=0,attr=0,palette='PAL_BATTLE_OB_RED'},'first native sprite projection') + T.same(rows[2],{x=48,y=40,tile=1,attr=32,palette='PAL_BATTLE_OB_RED'},'second native sprite retains tile and flip') local runner=View.runner(p) T.eq(runner.frames,1,'runner observes actual update clock') T.eq(runner.objects.structs[1].index,native.index,'runner observes actual native object identity') - T.eq(runner.objects.structs[1].jt,native.state,'runner observes actual object state machine') - local age,frame=native.age,p.frames + T.eq(runner.objects.structs[1].jt,native.jt,'runner observes actual object state machine') + local age,frame=native.duration,p.frames runner:oam();runner:oam() - T.eq(native.age,age,'OAM reads cannot tick object motion') + T.eq(native.duration,age,'OAM reads cannot tick object motion') T.eq(p.frames,frame,'OAM reads cannot tick script time') p:update();T.eq(runner.frames,2,'delay frames count on actual native timeline') - data.battle_anims.framesets[0].frames[1].xflip=true + p.runtime.data.framesets[p.objects.framesetOrder[1]][1][4]=0x20;p:update() rows=p:oam() T.eq(rows[1].x,32,'object X flip negates offset and eight-pixel cell width') T.eq(rows[1].attr,32,'object X flip reaches sprite attributes') T.eq(rows[2].attr,0,'object flip XORs a sprite already flipped') - data.battle_anims.framesets[0].frames[1].yflip=true + p.runtime.data.framesets[p.objects.framesetOrder[1]][1][4]=0x60;p:update() T.eq(p:oam()[1].y,32,'object Y flip negates native row offset') - native.fn,native.state='PokeBall',5 - p.opts.ballPalette='TEST_BALL' - T.eq(runner.objects.structs[1].func,'BATTLE_ANIM_FUNC_POKEBALL','source reads the actual native ball function') - T.eq(runner.objects.structs[1].jt,5,'source reads real landed ball state') - T.eq(p:oam()[1].palette,'TEST_BALL','typed native ball palette follows actual object') - p.vars.var,p.keepSprites=1,true + T.eq(runner.objects,p.objects,'public runner exposes the real mutable struct pool') + p.vars.var,p.runtime.keepSprites=1,true T.eq(runner.var,1,'capture result observation uses VM variable') T.eq(runner.keepSprites,true,'capture retention observation uses VM flag') p.presentation=runner local ticks=runner.frames p:start('FIX_MOVE',false) T.eq(runner.frames,ticks,'restarting VM cannot rebind previous animation observation') - T.eq(runner:oam()[1].palette,'TEST_BALL','retired OAM retains its own throw') + T.eq(runner:oam()[1].palette,'PAL_BATTLE_OB_RED','retired OAM retains its own actual palette') T.eq(p.frames,0,'new animation starts with its own actual clock') p:update() local g=love.graphics;local draw=g.draw;local calls={} diff --git a/tests/engine/gen2_bg_object_palette.lua b/tests/engine/gen2_bg_object_palette.lua index c9cc8b774..d034303f1 100644 --- a/tests/engine/gen2_bg_object_palette.lua +++ b/tests/engine/gen2_bg_object_palette.lua @@ -17,7 +17,7 @@ for _,edition in ipairs({'gold','silver','crystal'})do local B={{245,239,227},{31,97,193},{167,59,131},{7,13,19}} local C={{221,231,241},{71,81,91},{101,111,121},{11,21,31}} local data={battle_anims={gen2=true,moveAnims={PROBE=1}, - scripts={[1]={entry=1,code={{op='obp0',args={0x1b}}, + scripts={[1]={entry=1,code={{op='gfx',ids={1}},{op='obp0',args={0x1b}}, {op='obj',args={0,64,48,0}},{op='obj',args={1,72,48,0}}, {op='obj',args={2,80,48,0}},{op='delay',frames=2},{op='ret'}}}}, objects={},functions={[0]='Null'},framesets={[0]={frames={{oam=0,duration=8}},loop='hold'}}, diff --git a/tests/engine/gen2_native_bg_clock.lua b/tests/engine/gen2_native_bg_clock.lua index e60a10a37..2311bc0dd 100644 --- a/tests/engine/gen2_native_bg_clock.lua +++ b/tests/engine/gen2_native_bg_clock.lua @@ -33,9 +33,10 @@ for _,edition in ipairs({'gold','silver','crystal'})do for _=1,20 do local bg,frames=r.bg,r.frames end T.eq(ticks,frame,'repeated consumer observations never run the effect') end - p:update();T.eq(ticks,14,'the final command frame executes one BG pass') + p:update();T.eq(ticks,14,'the last wait frame executes one BG pass') + p:update();T.eq(ticks,15,'ret executes on the next native command frame') for _=1,20 do p:update()end - T.eq(ticks,14,'legacy object-tail drain cannot keep advancing a stopped BG program') + T.eq(ticks,15,'a stopped VM has no object-tail clock') local old=r.bg p:start('PROBE',not playerTurn) T.eq(r.bg,old,'retired consumer retains the previous register block') @@ -62,7 +63,7 @@ for _,edition in ipairs({'gold','silver','crystal'})do p:start('PROBE',true);p:update() local active=p.bg.effects[1] T.eq(active.jt,1,'native Teleport initializes its running state') - p:update() + p:update();p:update() T.eq(active.func,nil,'increment advances Teleport into its native terminal state') T.eq(p.bg.lcdc,nil,'native terminal state retires its LCD window') T.eq(p.bg.obp0,0x8c,'native OBJ0 palette command preserves byte semantics') From 7ea2eff1a08af510824baccc0cf079a1182b1740 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:20:16 +0100 Subject: [PATCH 084/187] Include ROM-free native GSC motion and import regression fixtures --- .../engine/gen2_animation_import_commands.lua | 61 ++++++++ tests/engine/gen2_object_motion_pixels.lua | 40 ++++++ tests/engine/gen2_object_runtime.lua | 135 ++++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 tests/engine/gen2_animation_import_commands.lua create mode 100644 tests/engine/gen2_object_motion_pixels.lua create mode 100644 tests/engine/gen2_object_runtime.lua diff --git a/tests/engine/gen2_animation_import_commands.lua b/tests/engine/gen2_animation_import_commands.lua new file mode 100644 index 000000000..30803ed7b --- /dev/null +++ b/tests/engine/gen2_animation_import_commands.lua @@ -0,0 +1,61 @@ +-- Real ROM-handler dispatch and script decoder, using synthetic device bytes. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local E=require('src.import.RomExtractorGen2') +local Player=require('src.battle.Gen2AnimPlayer') +local Importer=require('src.import.RomImporter') +local Version=require('src.core.GameVersion') +local previous=Version.get() +-- Older engine bases have the same real readiness gate without the newer +-- public cache descriptor. Seed its genuine marker, never a guessed format. +local markerFor +for i=1,100 do + local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end + if not name then break end +end +assert(markerFor) +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local e=E.new(nil,edition,{symbols={BattleAnimCommands={1,0x4000}, + BattleAnimCmd_BattlerGFX_1Row={1,0x4100}, + BattleAnimCmd_BattlerGFX_2Row={1,0x4120}}}) + e.rom={word=function(_,bank,address) + T.eq(bank,1,'decoder reads actual command-table bank') + if address==0x4012 then return 0x4100 end + if address==0x4014 then return 0x4120 end + return 0 + end,byte=function(_,bank,address) + return assert(({[0x5000]=0xd9,[0x5001]=0xda,[0x5002]=0xff})[address]) + end} + local script=e:gen2AnimScript(1,0x5000) + T.eq(script.code[1].op,'battlergfx_2row','D9 handler retains its crossed macro name') + T.eq(script.code[2].op,'battlergfx_1row','DA handler retains its crossed macro name') + local p=Player.new({battle_anims={gen2=true,moveAnims={PROBE=1},scripts={[1]=script}}}) + T.check(p:start('PROBE',true));p:update() + local r=p.runtime + T.eq(r.tileDict[1].tile,66,'D9 loads real one-row player-head tile base') + T.eq(r.tileDict[2].tile,73,'D9 loads real one-row enemy-foot tile base') + T.eq(r.tileDict[3].tile,53,'DA loads real two-row player-head tile base') + T.eq(r.tileDict[4].tile,67,'DA loads real two-row enemy-foot tile base') + T.eq(r.loaded[1].rows,1,'actual loaded descriptor has one row') + T.eq(r.loaded[3].rows,2,'actual loaded descriptor has two rows') + p:release() +end +for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + local marker=markerFor(edition) + T.check(marker:find('gen2%-anim%-object%-v1:')~=nil,'generated animation cache has a new contract') + Version.set(edition) + local old=marker:gsub('gen2%-anim%-object%-v1:','') + love.filesystem.write(Version.cachePrefix(edition)..'rom-cache.complete',old) + local report=Importer.readyReport(edition) + T.eq(report.ok,false,'old handler-labelled cache must be rebuilt') + T.check(report.why and report.why:find('another version',1,true)~=nil, + 'real readiness gate refuses the old format before checking file gaps') +end +for _,edition in ipairs({'red','emerald','platinum'})do + T.check(not markerFor(edition):find('gen2%-anim%-object'), + 'unrelated cartridge cache contract is retained') +end +Version.set(previous) +T.finish('native GSC crossed battler-row handler import and legacy-cache refusal') diff --git a/tests/engine/gen2_object_motion_pixels.lua b/tests/engine/gen2_object_motion_pixels.lua new file mode 100644 index 000000000..5cc50ceef --- /dev/null +++ b/tests/engine/gen2_object_motion_pixels.lua @@ -0,0 +1,40 @@ +-- Counterexample through the native player's actual OAM consumer. The old +-- archetype interpolator produces different pixels and retires at 48 frames. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Player=require('src.battle.Gen2AnimPlayer') +local previous=Version.get() +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + for _,player in ipairs({true,false})do + local data={battle_anims={gen2=true,moveAnims={PROBE=1}, + scripts={[1]={entry=1,code={{op='gfx',ids={1}}, + {op='obj',args={0,64,96,4}},{op='delay',frames=200},{op='ret'}}}}, + objects={[0]={oamFlags=1,fixY=255,frameset=0,fn=0,palette=2,gfx=1}}, + functions={[0]='MoveFromUserToTarget'}, + framesets={[0]={frames={{oam=0,duration=1}},loop='restart'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}}}, + gfx={[1]={tiles=1,cols=1,image='native-motion'}}}} + local p=Player.new(data);T.check(p:start('PROBE',player)) + for frame=1,30 do + p:update() + local x=math.min(132,64+4*frame) + local y=96-math.min(17,frame)*2 + local row=p:oam()[1] + T.eq(row and row.x,player and x or 180-x,'actual cartridge X step and stop threshold') + T.eq(row and row.y,player and y or 40+y,'actual cartridge half-speed Y step') + end + for _=31,60 do p:update()end + T.eq(#p:oam(),1,'restarting object remains visible after 48 frames') + for _=61,202 do p:update()end + T.eq(p:isDone(),true,'full wait followed by return has no synthetic tail') + T.eq(#p:oam(),0,'normal top-level return immediately clears OAM') + local terminal=p.frames + for _=1,20 do p:update()end + T.eq(p.frames,terminal,'no clocks run after terminal return') + p:release() + end +end +Version.set(previous) +T.finish('native GSC actual object pixels, cartridge lifetime and terminal timing') diff --git a/tests/engine/gen2_object_runtime.lua b/tests/engine/gen2_object_runtime.lua new file mode 100644 index 000000000..e3e2e5b42 --- /dev/null +++ b/tests/engine/gen2_object_runtime.lua @@ -0,0 +1,135 @@ +-- Actual imported native commands through the GSC object/VM owner. +-- Expectations name cartridge behaviour, not a second simulated controller. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Player=require('src.battle.Gen2AnimPlayer') +local Version=require('src.core.GameVersion') +local previous=Version.get() +local function fixture(code,fn,param,frames) + return {battle_anims={gen2=true,moveAnims={PROBE=1}, + scripts={[1]={entry=1,code=code}}, + functions={[0]=fn or 'Null'}, + objects={[0]={oamFlags=1,fixY=255,frameset=0,fn=0,palette=2,gfx=1}}, + framesets={[0]=frames or {frames={{oam=0,duration=1}},loop='restart'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}}}, + gfx={[1]={tiles=1,cols=1,image='native-object'}}}} +end +local function make(fn,param,player,frames,code) + local p=Player.new(fixture(code or {{op='gfx',ids={1}}, + {op='obj',args={0,64,96,param or 0}}, + {op='delay',frames=200},{op='ret'}},fn,param,frames)) + T.check(p:start('PROBE',player~=false),'actual native imported program starts') + return p +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + for _,player in ipairs({true,false})do + local p=make('MoveFromUserToTarget',4,player) + p:update() + local r=p.runtime + local s=r.objects.structs[1] + T.eq(p.objects,r.objects,'native player exposes actual ten-slot pool') + T.eq(p.bg,r.objects.hram,'object functions and BG share actual HRAM') + T.eq(r.env,p.bg.env,'both pools read the same battle environment') + T.eq(s.func,'BATTLE_ANIM_FUNC_USER_TO_TARGET','imported function name resolves real routine') + T.eq(s.x,68,'target routine advances PARAM pixels on its first frame') + T.eq(s.y,94,'target routine advances half as many vertical pixels') + local row=r:oam()[1] + T.eq(row.x,player and 68 or 112,'enemy mirror is applied at OAM output, after motion') + T.eq(row.y,player and 94 or 134,'enemy Y fix is applied at OAM output') + for _=1,17 do p:update()end + T.eq(s.x,132,'target routine stops at its cartridge threshold') + T.eq(s.y,62,'target movement uses actual coordinate state') + T.eq(s.xOffset,0,'target movement is not an interpolated visual offset') + for _=1,42 do p:update()end + T.eq(s.index,1,'restarting frameset object survives beyond former 48-frame cap') + T.eq(r.frames,60,'only native updates advance the interpreter') + for _=1,30 do r:oam()end + T.eq(r.frames,60,'drawing and observation never step the interpreter') + T.eq(s.index,1,'OAM observation cannot retire an object') + for _=61,201 do p:update()end + T.eq(p:isDone(),false,'real anim_wait200 retains all two hundred wait frames') + p:update() + T.eq(r.frames,202,'top-level return follows command frame and full wait') + T.eq(p:isDone(),true,'top-level return is immediately terminal') + T.eq(#r:oam(),0,'terminal normal animation clears OAM immediately') + for _=1,25 do p:update()end + T.eq(r.frames,202,'terminal animation has no arbitrary drain clock') + local old=r + p:start('PROBE',not player) + T.check(old~=p.runtime,'restart creates a distinct real interpreter') + T.eq(old.frames,202,'retained old interpreter keeps its own time') + T.eq(old.objects.structs[1],s,'retained consumer keeps actual old object identity') + p:release() + end + do + local p=make('Null',0,true,nil,{{op='gfx',ids={1}}, + {op='obj',args={0,64,96,0}},{op='delay',frames=1}, + {op='incobj',args={1}},{op='delay',frames=1},{op='ret'}}) + p:update();local s=p.objects.structs[1] + p:update();T.eq(s.index,1,'wait burns a complete native frame') + p:update() + T.eq(s.jt,1,'incobj changes real jump-table state instead of choosing a frameset') + T.eq(s.index,0,'Null routine deinitializes through its actual state machine') + T.eq(#p:oam(),1,'deinitializing function still emits OAM on that same frame') + p:update();T.eq(#p:oam(),0,'the next OAM pass skips the retired slot') + p:update();T.eq(p:isDone(),true,'no synthetic object-tail delay follows ret') + p:release() + end + do + local p=make('MoveInCircle',0x88) + p:update();local s=p.objects.structs[1] + T.eq(s.param,8,'circle bit7 selects phase, not radius') + T.eq(s.var1,33,'circle init falls through into its first motion on the same frame') + T.eq(s.xOffset,248,'half-turn circle offset uses a signed wrapping byte') + T.eq(s.yOffset,0,'half-turn circle starts on the horizontal diameter') + p:release() + end + do + local code={{op='gfx',ids={1}}} + for _=1,11 do code[#code+1]={op='obj',args={0,64,96,0}}end + code[#code+1]={op='delay',frames=1} + code[#code+1]={op='clearobjs'} + code[#code+1]={op='delay',frames=1} + code[#code+1]={op='ret'} + local p=make('Null',0,true,nil,code) + p:update();T.eq(p.objects:activeCount(),10,'hardware pool drops the eleventh object') + T.eq(p.objects.lastIndex,10,'dropped object does not consume an index') + p:update();p:update() + T.eq(p.objects:activeCount(),3,'cartridge ClearObjs preserves slots eight through ten') + T.eq(p.objects.structs[8].index,8,'retained hardware slot keeps identity') + p:release() + end + do + local p=make('Null',0,true) + local sprites=p.runtime.data.oamsets[0].sprites + for i=2,50 do sprites[i]={x=i-1,y=0,tile=0,attr=0}end + p:update();T.eq(#p:oam(),40,'shadow OAM is bounded by actual forty-entry hardware limit') + p:release() + end + do + local p=make('Null',0,true,{frames={{wait=true,duration=1}, + {oam=0,duration=0}},loop='delete'}) + p:update();T.eq(#p:oam(),0,'oamwait emits no sprite on its command frame') + p:update();T.eq(#p:oam(),0,'oamwait preserves its full duration') + p:update();T.eq(#p:oam(),1,'next actual frameset row emits its sprite') + p:update();T.eq(p.objects:activeCount(),0,'oamdelete deinitializes its actual struct') + p:release() + end + do + local p=make('Null',0,true,nil,{{op='gfx',ids={1}}, + {op='obj',args={0,64,96,0}},{op='delay',frames=1}, + {op='keepsprites'},{op='ret'}}) + p:update();p:update();p:update() + T.eq(p:isDone(),true,'capture terminal is immediate') + T.eq(p:oam()[1].palette,'PAL_BATTLE_OB_ENEMY','retained OAM follows native terminal palette rule') + local kept=p:finalSprites() + T.eq(kept[1].x,64,'captured terminal keeps the actual final OAM') + local old=p.runtime;p:start('PROBE',false) + T.eq(kept.runner,old,'retained ball drawing keeps its genuine runner metadata') + T.eq(kept[1].x,64,'new animation cannot change the retained ball coordinates') + p:release() + end +end +Version.set(previous) +T.finish('native GSC object motion, hardware slots, OAM, waits and terminal ownership') From 91f27b2dea50854a3541cda1a1061eae4b9a2841 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:25:52 +0100 Subject: [PATCH 085/187] Record fork ROM traces separately from upstream GPU qualification --- compat/gen1-gsc/provenance.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compat/gen1-gsc/provenance.json b/compat/gen1-gsc/provenance.json index d8931f7b6..5114142c5 100644 --- a/compat/gen1-gsc/provenance.json +++ b/compat/gen1-gsc/provenance.json @@ -27,5 +27,5 @@ "The native importer preserves the crossed battler-row macro/handler names. A Gen2-only cache revision rejects old generated commands and preserves game saves." ], "licence": "Gen1Recomp GPLv3 with its Additional Terms, retained in LICENSE.MD. Existing authors retain their rights; Roxas2712 integration changes in these derived files are offered on those applicable terms.", - "qualification": "ROM-free actual-pixel counterexample fails 366 checks on the preceding native player; replacement passes. Initial actual Crystal ROM comparison matches 251 move and 23 extra-animation programs on both sides with four parameters (2192 cases); six high-parameter cases reach the observation limit equally on both interpreters. Expanded traces, new GPU frames, Windows and packaged release qualification remain pending. Earlier Metal battle ce12c485 predates the object/VM replacement." + "qualification": "Actual Crystal ROM command/object/BG/OAM comparison against pinned Gen1Recomp535a5ec0: 251 moves and 23 extra programs, both sides, five parameters plus ball/shake/catch cases, 2804 cases and355718 compared frames, no mismatch or observation-limit case. Source e96a8edc; later publication changes only comments, provenance and cache-gate fixture access. Fork a0474fcd passes7893 checks in19 ROM-free groups; independent upstream7ea2eff1 passes5313 in14. These are CPU observations; the old ce12c485 Metal battle predates this VM. New unchanged-client GPU, all picture-override rendering, full send-out/event integration, Windows and packaged release qualification remain open." } From afde9aea0ad4a89f34d0f8b54216c1c1e44473c9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:06:21 +0100 Subject: [PATCH 086/187] Apply GSC command-time picture ownership to native controller --- .../workflows/gen2-picture-presentation.yml | 26 +++ src/battle/BattleState.lua | 36 +++- src/battle/Gen2PicturePresentation.lua | 164 ++++++++++++++++++ src/battle/MoveEffects.lua | 5 +- tests/engine/gen2_picture_pixels.lua | 83 +++++++++ tests/engine/gen2_picture_presentation.lua | 160 +++++++++++++++++ 6 files changed, 465 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/gen2-picture-presentation.yml create mode 100644 src/battle/Gen2PicturePresentation.lua create mode 100644 tests/engine/gen2_picture_pixels.lua create mode 100644 tests/engine/gen2_picture_presentation.lua diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml new file mode 100644 index 000000000..8c2491985 --- /dev/null +++ b/.github/workflows/gen2-picture-presentation.yml @@ -0,0 +1,26 @@ +name: Native GSC picture and animation owners +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gen2-picture-${{ github.ref }} + cancel-in-progress: true +jobs: + picture-contract: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - name: Install LuaJIT + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Native picture commands and emitted images + run: | + luajit tests/engine/gen2_picture_presentation.lua + luajit tests/engine/gen2_picture_pixels.lua + - name: Existing command, object and background owners + run: | + luajit tests/engine/gen2_object_runtime.lua + luajit tests/engine/gen2_object_motion_pixels.lua + luajit tests/engine/gen2_bg_presenter.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 690b5ceac..1d88b45ec 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2737,6 +2737,7 @@ function BattleState:updateQueue() end if self.animPlaying then self.animPlayer:update() + if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').sync(self) end if self.animPlayer.pollEffects and self.applyAnimEffect then for _, ev in ipairs(self.animPlayer:pollEffects()) do self:applyAnimEffect(ev) @@ -2776,6 +2777,7 @@ function BattleState:updateQueue() return true end if not self.current then + if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').sync(self) end local item = table.remove(self.queue, 1) if not item then return false end if item.fn then @@ -3021,6 +3023,10 @@ function BattleState:updateQueue() self:applyHitFx(item.hit) end end + if GameVersion.isGen2() then + require('src.battle.Gen2PicturePresentation').admitted(self,item, + self.animPlaying and self.anim or nil) + end self.current = nil return true end @@ -4175,6 +4181,14 @@ end -- frame if one is up, otherwise the still. Placement is always measured off -- the still (same dimensions, same ground padding), so only the texture -- changes -- see drawBattlerPic and the enemy branch of drawPics. +function BattleState:pictureSprite(battler) + return battler.sprite +end + +function BattleState:pictureStill(battler) + return self:picImage(self:pictureSprite(battler)) +end + function BattleState:battlerPic(battler) local still = self:picImage(battler.sprite) local anim = battler and battler.picAnim @@ -11694,6 +11708,10 @@ function BattleState:drawBattlerPic(battler, x, y, scale) if not ok then error(err) end end +function BattleState:substituteVisible(battler) + return battler and battler.substituteHP +end + function BattleState:drawBattlerPicAt(battler, x, y, scale) local shakeX, shakeY = self:gen3AnimShake(battler) x, y = x + shakeX, y + shakeY @@ -11729,7 +11747,7 @@ function BattleState:drawBattlerPicAt(battler, x, y, scale) end end local img = self:battlerPic(battler) - if battler.substituteHP and not self:fxFaintActive(battler) + if self:substituteVisible(battler) and not self:fxFaintActive(battler) and not battler.fainted then self:drawSubstituteDoll(battler) return @@ -12340,7 +12358,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- ball's timeline: it shrinks into the ball and comes back out of it, -- and once it is inside there is nothing to draw at all. and not ((self:gen3BallFor(self.enemy) or {}).monHidden) then - local img = self:picImage(self.enemy.sprite) + local img = self:pictureStill(self.enemy) love.graphics.setColor(1, 1, 1, 1) local ex, ey = enemyPicXY(img, slide, sx, sy) local s = BattleState.resolveBattleScale(self.data, "front", @@ -12467,13 +12485,14 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- air there is no Pokemon on that side to draw; without this it stood -- there at full size waiting for its own ball to arrive. and not ((self:gen3BallFor(self.player) or {}).monHidden) then - local img = self:picImage(self.player.sprite) + local img = self:pictureStill(self.player) love.graphics.setColor(1, 1, 1, 1) -- feet flush on the text box top (y=96), ignoring baked-in padding - local pad = imagePadBottom[self.player.sprite] or 0 - local padL = imagePadLeft[self.player.sprite] or 0 + local handle = self:pictureSprite(self.player) + local pad = imagePadBottom[handle] or 0 + local padL = imagePadLeft[handle] or 0 local s = BattleState.resolveBattleScale(self.data, "back", - imagePathOf(self.player.sprite), + imagePathOf(handle), self.player.mon and self.player.mon.species) local gs = self:growInScale(self.player) local ballOut = self:gen3BallFor(self.player) @@ -12496,7 +12515,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) local dx, dy if self:gen3Layout() then dx, dy = Gen3Battle.picPlacement(self, self.player, img, - imagePathOf(self.player.sprite), s) + imagePathOf(handle), s) else dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(), pad, padL, s) @@ -12516,7 +12535,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) love.graphics.setColor(1, 1, 1, 1) elseif self:gen3Layout() then local dx, dy = Gen3Battle.picPlacement(self, self.player, img, - imagePathOf(self.player.sprite), s) + imagePathOf(handle), s) self:drawBattlerPic(self.player, dx + sx, dy + sy, s) else local dx, dy = BattleState.backPlacement(img:getWidth(), @@ -13050,5 +13069,6 @@ require('src.battle.Gen2BattleScene').install(BattleState) -- Compose after native/source view installers, retaining their real draw owners. require('src.battle.Gen2BgPictures').install(BattleState) +require('src.battle.Gen2PicturePresentation').install(BattleState) return BattleState diff --git a/src/battle/Gen2PicturePresentation.lua b/src/battle/Gen2PicturePresentation.lua new file mode 100644 index 000000000..8ce55b5e0 --- /dev/null +++ b/src/battle/Gen2PicturePresentation.lua @@ -0,0 +1,164 @@ +-- Picture identity follows the existing GSC animation VM, not the earlier +-- native damage/effect calculation. No combat value or saved mon is delayed. +local Version=require('src.core.GameVersion') +local M={} + +local function side(b) return b.isPlayer and 'player' or 'enemy' end +local function current(screen,p) + return screen[side(p.b)]==p.b and p.b.mon==p.mon +end +local function commit(p) + if p.kind=='transform' then + p.b.species=p.species + p.b.picAnim=p.picAnim + end +end +local function remove(screen,index,apply) + local p=table.remove(screen._gen2PictureChanges,index) + if apply and current(screen,p) then commit(p) end +end +local function pending(screen,b,kind) + for _,p in ipairs(screen._gen2PictureChanges or {})do + if p.b==b and p.mon==b.mon and (not kind or p.kind==kind)then return p end + end +end +local function mark(screen,b,kind) + local row=screen.moveAnimRow + local player=screen.animPlayer + if not (Version.isGen2() and row and screen:animationsOn() and player and player.anims.gen2 + and player:scriptFor(row.anim))then return end + local queued=false + for _,item in ipairs(screen.queue or {})do if item==row then queued=true;break end end + if not queued then return end + local p={b=b,mon=b.mon,row=row,kind=kind} + screen._gen2PictureChanges=screen._gen2PictureChanges or {} + table.insert(screen._gen2PictureChanges,p) + return p +end + +function M.transform(screen,b,target) + local function resolved(who) + local species=who.species or who.mon.species + for _,p in ipairs(screen._gen2PictureChanges or {})do + if p.b==who and p.mon==who.mon and p.kind=='transform'then species=p.species end + end + return species + end + local species,before=resolved(target),resolved(b) + -- Front entrance frames belong to this battler, never the target's live + -- runner. A back picture has no front entrance animation to borrow. + local animation=not b.isPlayer and require('src.pokemon.PicAnim').new(screen.data,species) or nil + local p=mark(screen,b,'transform') + if p then + p.beforeSprite,p.beforeSpecies=b.sprite,before + p.species,p.picAnim=species,animation + end + b.sprite=screen:speciesSprite(species,b.isPlayer) or b.sprite + if not p then commit({kind='transform',b=b,species=species,picAnim=animation})end +end + +function M.substitute(screen,b) + if Version.isGen2() then mark(screen,b,'substitute') end +end + +-- Native row admission binds the one actual VM (or selects the normal +-- disabled/missing-data fallback), never a parallel animation owner. +function M.admitted(screen,row,runner) + for i=#(screen._gen2PictureChanges or {}),1,-1 do + local p=screen._gen2PictureChanges[i] + if p.row==row then + if runner then p.runner=runner else remove(screen,i,true) end + end + end +end + +-- Called after the native VM tick, before an after-animation replaces it. +-- Drawing only reads; it cannot advance this owner. +function M.sync(screen) + local i=1 + while i<=#(screen._gen2PictureChanges or {}) do + local p=screen._gen2PictureChanges[i] + local discard,apply=false,true + if not current(screen,p)then discard,apply=true,false + elseif p.runner then + local value=p.runner.picOverride[side(p.b)] + discard=p.runner.stop or value==p.kind + else + local queued=false + for _,row in ipairs(screen.queue or {})do if row==p.row then queued=true;break end end + discard=not queued + end + if discard then remove(screen,i,apply)else i=i+1 end + end +end + +function M.override(screen,b) + local overrides=screen.anim and screen.anim.picOverride + local value=overrides and overrides[side(b)] + -- false is an explicit dropsub, distinct from an unmodified tilemap. + if value~=nil then return value end + if pending(screen,b,'substitute')then return false end + return b.substituteHP and 'substitute' or nil +end + +function M.species(screen,b) + if not Version.isGen2()then return b.species or b.mon.species end + local p=pending(screen,b,'transform') + if p then return p.beforeSpecies end + local value=M.override(screen,b) + if type(value)=='table' and value.kind=='beatup' then + -- The wire parameter is wCurPartySpecies, not an index into the party. + -- Keep the pinned VM's public `slot` field for compatibility. + local catalog=screen.data.pokemon + local cached=screen._gen2BeatUpIdentity + if not(cached and cached.value==value and cached.catalog==catalog and cached.index==value.slot)then + cached={value=value,catalog=catalog,index=value.slot} + for name,def in pairs(catalog or {})do + if type(def)=='table' and def.index==value.slot then cached.species=name;break end + end + screen._gen2BeatUpIdentity=cached + end + if cached.species then return cached.species end + end + return b.species or b.mon.species +end + +function M.sprite(screen,b) + if not Version.isGen2()then return b.sprite end + local p=pending(screen,b,'transform') + if p then return p.beforeSprite end + local species=M.species(screen,b) + if species~=(b.species or b.mon.species)then + return screen:speciesSprite(species,b.isPlayer) or b.sprite + end + return b.sprite +end + +function M.install(Screen) + local sprite=Screen.pictureSprite + function Screen:pictureSprite(b) + if Version.isGen2()then return M.sprite(self,b)end + return sprite(self,b) + end + local pic=Screen.battlerPic + function Screen:battlerPic(b) + if Version.isGen2() and (pending(self,b,'transform') or M.sprite(self,b)~=b.sprite)then + return self:pictureStill(b) + end + return pic(self,b) + end + local visible=Screen.substituteVisible + function Screen:substituteVisible(b) + if Version.isGen2() and b then return M.override(self,b)=='substitute'end + return visible(self,b) + end + local read=Screen.animPicState + function Screen:animPicState(which) + local state=read and read(self,which) + if Version.isGen2() and self[which]then + state=state or {};state.pic=M.override(self,self[which]) + end + return state + end +end +return M diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 5772141ae..783c34151 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -360,6 +360,7 @@ MoveEffects.primary = { return { Strings("Too weak to make\na SUBSTITUTE!") } end user.mon.hp = user.mon.hp - cost + if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').substitute(battle,user) end user.substituteHP = cost + 1 -- _SubstituteText return { Strings("It created a\nSUBSTITUTE!") } @@ -386,7 +387,9 @@ MoveEffects.primary = { -- mosaic and HandleSpeciesGfxDataChange performs it at callback 46. -- Gen3MoveAnim owns that visual midpoint, so changing the FireRed sprite -- here would expose the copied species for the first 45 callbacks. - if GameVersion.get() ~= "firered" then + if GameVersion.isGen2() then + require('src.battle.Gen2PicturePresentation').transform(battle,user,target) + elseif GameVersion.get() ~= "firered" then user.sprite = battle:speciesSprite(target.mon.species, user.isPlayer) or user.sprite -- Black / White draw a live animation over `sprite`, so their picture diff --git a/tests/engine/gen2_picture_pixels.lua b/tests/engine/gen2_picture_pixels.lua new file mode 100644 index 000000000..f05cf15cf --- /dev/null +++ b/tests/engine/gen2_picture_pixels.lua @@ -0,0 +1,83 @@ +-- Actual existing engine entry points and emitted image calls. The old +-- engine can run this fixture without adding any new helper module. +-- This is a CPU draw-stream counterexample, not GPU pixel qualification. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Effects=require('src.battle.MoveEffects').primary +local before=Version.get() +local function make(edition,side,op) + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.PROBE={id='PROBE',power=0,type='NORMAL',pp=10,name='PROBE'} + data.battle_anims={gen2=true,moveAnims={PROBE=1},scripts={ + [1]={entry=1,code={{op='delay',frames=3},{op=op},{op='delay',frames=4},{op='ret'}}}, + }} + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s.introSlide,s.sendingOut,s.enemySendingOut=0,false,false + s.showPlayerBack,s.showEnemyTrainer=false,false + local row={anim='PROBE',attackerIsPlayer=side=='player',animDelayed=true} + s.queue[1],s.moveAnimRow=row,row + return s,s[side],s[side=='player'and'enemy'or'player'],row +end +local calls,draw={},love.graphics.draw +love.graphics.draw=function(image,...)calls[#calls+1]=image;return draw(image,...)end +local function capture(s,b,expected,label) + calls={};s:drawPicsLayer(0,0,0,b.isPlayer and 'player'or'enemy',true) + T.eq(#calls,1,label..' emits exactly one image') + T.eq(calls[1],expected,label..' emits correct picture, not metadata alone') + if s.drawPic then + calls={};s:drawPic(b.mon,b.isPlayer) + T.eq(#calls,1,label..' public capture emits exactly one image') + T.eq(calls[1],expected,label..' public capture shares actual image owner') + end +end +local function command(s,side,expected) + local n=0 + while s.anim.picOverride[side]~=expected and n<100 do s:updateQueue();n=n+1 end + T.check(n>0 and n<100,'native queue reaches actual image opcode') +end +for _,edition in ipairs({'gold','silver','crystal'})do for _,side in ipairs({'player','enemy'})do + local s,b,target=make(edition,side,'transform') + local old=s:picImage(b.sprite) + Effects.TRANSFORM_EFFECT(s,b,target) + local replacement=s:picImage(b.sprite) + T.neq(old,replacement,'fixture uses distinct real image owners') + capture(s,b,old,'queued Transform') + s:updateQueue();capture(s,b,old,'pre-command Transform') + command(s,side,'transform') + capture(s,b,replacement,'post-command Transform') + T.eq(b.mon.species,side=='player'and'FIXMON_A'or'FIXMON_C','actual saved identity is retained') + + s,b=make(edition,side,'raisesub');old=s:picImage(b.sprite) + Effects.SUBSTITUTE_EFFECT(s,b) + capture(s,b,old,'queued Substitute') + s:updateQueue();capture(s,b,old,'pre-command Substitute') + command(s,side,'substitute') + calls={};s:drawPicsLayer(0,0,0,side,true) + T.eq(#calls,1,'post-command Substitute emits actual doll') + T.neq(calls[1],old,'doll image is distinct from normal Pokemon') + + s,b=make(edition,side,'dropsub');b.substituteHP=17;old=s:picImage(b.sprite) + s:updateQueue();command(s,side,false) + capture(s,b,old,'explicit dropsub despite retained substitute HP') + T.eq(b.substituteHP,17,'image command retains real combat substitute') + + local row + s,b,target,row=make(edition,side,'beatup') + row.animParam=207;s.data.pokemon[target.mon.species].index=207 + replacement=s:picImage(s:speciesSprite(target.mon.species,b.isPlayer)) + s:updateQueue() + local n=0 + while not s.anim.picOverride[side]and n<100 do s:updateQueue();n=n+1 end + T.check(n>0 and n<100,'native Beat Up opcode actually runs') + capture(s,b,replacement,'Beat Up species 207 outside party slots') +end end +love.graphics.draw=draw +Version.set(before) +T.finish('actual native GSC picture draw-stream parity') diff --git a/tests/engine/gen2_picture_presentation.lua b/tests/engine/gen2_picture_presentation.lua new file mode 100644 index 000000000..1b322ee25 --- /dev/null +++ b/tests/engine/gen2_picture_presentation.lua @@ -0,0 +1,160 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Effects=require('src.battle.MoveEffects').primary +local Pictures=require('src.battle.Gen2PicturePresentation') +local before=Version.get() +local function make(edition,side,op) + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.PIC_TEST={id='PIC_TEST',name='PICTURE TEST',type='NORMAL',pp=10,power=0} + data.battle_anims={gen2=true,moveAnims={PIC_TEST=1},scripts={ + [1]={entry=1,code={{op='delay',frames=3},{op=op},{op='delay',frames=3},{op='ret'}}}, + }} + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s.introSlide,s.sendingOut,s.enemySendingOut=0,false,false + s.showPlayerBack,s.showEnemyTrainer=false,false + local row={anim='PIC_TEST',attackerIsPlayer=side=='player',animDelayed=true} + s.queue[1],s.moveAnimRow=row,row + return s,s[side],s[side=='player'and'enemy'or'player'],row +end +local function endAnim(s) + local count=0 + while s.animPlaying and count<120 do s:updateQueue();count=count+1 end + T.check(count<120,'picture program releases its native queue') +end +for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + for _,side in ipairs({'player','enemy'})do + local s,b,target,row=make(edition,side,'transform') + local sprite,species,hp,pp=b.sprite,b.mon.species,b.mon.hp,b.curMoves[1].pp + local savedStats=b.mon.stats + Effects.TRANSFORM_EFFECT(s,b,target) + local copied=b.sprite + T.neq(copied,sprite,'native effect resolves its replacement immediately') + T.eq(b.curStats.attack,target.curStats.attack,'native copied combat stats are not delayed') + T.eq(b.mon.stats,savedStats,'saved stats retain their owner') + T.eq(s:pictureStill(b),s:picImage(sprite),'queued view retains the actual preceding image') + T.eq(s:battlerPic(b),s:picImage(sprite),'native drawer retains preceding image') + if s.pic then + T.eq(s:pic(b.mon,b.isPlayer),s:picImage(sprite),'same-side public capture retains preceding image') + local front,_,path=s:pic(b.mon,false) + T.check(path:find('fixmon_a',1,true)or path:find('fixmon_c',1,true), + 'opposite capture resolves an existing species path') + end + T.eq(Pictures.species(s,b),species,'queued front identity precedes actual transform command') + s:updateQueue() + T.eq(s.animPlaying,true,'actual native queue starts picture program') + local r=s.anim + T.eq(s._gen2PictureChanges[1].runner,r,'pin is bound to actual command interpreter') + local ticks=0 + while r.picOverride[side]~='transform'and ticks<120 do + T.eq(s:battlerPic(b),s:picImage(sprite),'all pre-command frames show preceding species') + local frames=r.frames + for _=1,3 do + if s.pic then s:pic(b.mon,false)end + s:animPicState(side);s:battlerPic(b) + end + T.eq(r.frames,frames,'drawing never advances the VM') + s:updateQueue();ticks=ticks+1 + end + T.check(ticks>0 and ticks<120,'actual transform opcode is reached') + T.eq(s:battlerPic(b),s:picImage(copied),'the opcode reveals the actual replacement') + T.eq(b.species,target.mon.species,'public species identity commits at that same command') + T.eq(Pictures.species(s,b),target.mon.species,'opposite capture follows transformed identity') + T.eq(#s._gen2PictureChanges,0,'revealed picture releases its transient pin') + endAnim(s) + T.eq(b.mon.species,species,'saved species is never transformed') + T.eq(b.mon.hp,hp,'picture commands do not change HP') + T.eq(b.curMoves[1].pp,5,'native Transform still supplies copied moves at five PP') + T.eq(b.mon.moves[1].pp,pp,'saved PP is not consumed by picture reads') + + s,b,target,row=make(edition,side,'raisesub') + local oldHP=b.mon.hp + Effects.SUBSTITUTE_EFFECT(s,b) + T.check(b.mon.hp0,'native substitute cost resolves before animation') + T.eq(s:substituteVisible(b),false,'new doll is held until real raisesub command') + T.eq(s:animPicState(side).pic,false,'explicit preceding normal picture is observable') + s:updateQueue();r=s.anim;ticks=0 + while r.picOverride[side]~='substitute'and ticks<120 do + T.eq(s:substituteVisible(b),false,'pre-command frames retain normal picture') + s:updateQueue();ticks=ticks+1 + end + T.check(ticks<120,'real raisesub command executes') + T.eq(s:substituteVisible(b),true,'actual raisesub reveals doll') + T.eq(#s._gen2PictureChanges,0,'doll reveal retires transient creation pin') + endAnim(s) + T.eq(s:substituteVisible(b),true,'native substitute survives animation cleanup') + + s,b=make(edition,side,'dropsub');b.substituteHP=17 + s:updateQueue();r=s.anim;ticks=0 + while r.picOverride[side]~=false and ticks<120 do s:updateQueue();ticks=ticks+1 end + T.check(ticks<120,'actual dropsub command executes') + T.eq(s:animPicState(side).pic,false,'false override survives public observation') + T.eq(s:substituteVisible(b),false,'dropsub paints Pokemon despite positive combat substitute HP') + local dolls,draw=0,s.drawSubstituteDoll + s.drawSubstituteDoll=function()dolls=dolls+1 end + if s.drawPic then s:drawPic(b.mon,false) + else s:drawPicsLayer(0,0,0,side,true)end + T.eq(dolls,0,'actual public capture no longer paints hidden doll') + s.drawSubstituteDoll=draw + T.eq(b.substituteHP,17,'temporary drawing never destroys combat substitute') + endAnim(s) + T.eq(s:substituteVisible(b),true,'finishing move restores native retained substitute') + + s,b=make(edition,side,'beatup') + local species=b.mon.species + local chosen=side=='player'and'FIXMON_C'or'FIXMON_A' + s.data.pokemon[chosen].index=207 -- deliberately outside a six-member party + row=s.queue[1];row.animParam=207 + s:updateQueue();r=s.anim;ticks=0 + while not r.picOverride[side]and ticks<120 do s:updateQueue();ticks=ticks+1 end + T.check(ticks<120,'actual Beat Up command executes') + T.eq(Pictures.species(s,b),chosen,'Beat Up wire byte resolves species, not party slot') + T.eq(s:pictureStill(b),s:picImage(s:speciesSprite(chosen,b.isPlayer)), + 'native placement measures actual Beat Up image') + T.eq(s:battlerPic(b),s:pictureStill(b),'native drawer uses actual Beat Up image') + if s.pic then T.eq(s:pic(b.mon,b.isPlayer),s:pictureStill(b),'public same-side capture uses same image')end + T.eq(b.mon.species,species,'Beat Up never replaces the active saved Pokemon') + endAnim(s) + T.eq(Pictures.species(s,b),species,'Beat Up temporary picture is released with native VM') + end +end + +-- Missing/disabled/cancelled programs and a real slot replacement cannot +-- leave a pinned picture hanging or repaint the incoming saved Pokemon. +for _,mode in ipairs({'disabled','missing','cancelled','stopped','replacement'})do + local s,b,target,row=make('crystal','player','transform') + local species=b.mon.species + if mode=='disabled'then s.game.save.options.animations=false end + if mode=='missing'then s.animPlayer.anims.scripts[1]=nil end + Effects.TRANSFORM_EFFECT(s,b,target) + if mode=='cancelled'then s:cancelMoveAnim();Pictures.sync(s) + elseif mode=='replacement'then + local incoming=Pokemon.new(s.data,'FIXMON_B',18) + if s.switchPlayerInto then s:switchPlayerInto(Battle.POS.PLAYER_LEFT,incoming) + else s.player={mon=incoming,isPlayer=true,sprite={}}end + Pictures.sync(s) + T.eq(s.player.mon,incoming,'native switch retains actual incoming mon') + T.eq(Pictures.species(s,s.player),'FIXMON_B','old transform cannot repaint replacement slot') + elseif mode=='stopped'then + s:updateQueue();s.anim.stop=true;s:updateQueue() + else s:updateQueue()end + T.eq(#(s._gen2PictureChanges or {}),0,mode..' releases native picture pin') + if mode~='replacement'then T.eq(b.species,target.mon.species,mode..' commits resolved native visual identity')end + T.eq(b.mon.species,species,mode..' retains saved species') +end +for _,edition in ipairs({'red','emerald','firered'})do + Version.set(edition) + local s,b=make('crystal','player','dropsub');Version.set(edition) + b.substituteHP=17;s.anim={picOverride={player=false}} + T.check(s:substituteVisible(b),edition..' keeps its original substitute owner') + T.eq(s:pictureSprite(b),b.sprite,edition..' keeps native image owner') +end +Version.set(before) +T.finish('native GSC command-time picture identity and substitute visibility') From 44b6122ea51e9f5a902d29335e8ac80eed6fc800 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:09:11 +0100 Subject: [PATCH 087/187] Preserve repeated native picture order and document qualification limits --- docs/native-gen2-picture-presentation.md | 43 ++++++++++++++++++++++ src/battle/Gen2PicturePresentation.lua | 7 ++-- tests/engine/gen2_picture_presentation.lua | 29 +++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 docs/native-gen2-picture-presentation.md diff --git a/docs/native-gen2-picture-presentation.md b/docs/native-gen2-picture-presentation.md new file mode 100644 index 000000000..18f75f80d --- /dev/null +++ b/docs/native-gen2-picture-presentation.md @@ -0,0 +1,43 @@ +# Native GSC picture-command ownership + +The shared native controller resolves a move before its queued animation runs. +Changing the sprite at that earlier point exposed Transform's copied Pokemon +and Substitute's doll before the ROM command. The existing renderer also read +`substituteHP` despite an explicit `dropsub` override, and ignored Beat Up's +temporary species image. + +`Gen2PicturePresentation` holds only presentation identity. A pin belongs to the +actual battler, saved Pokemon and queued row, then to the actual GSC runner. +Native stats, moves, HP, PP and turn resolution retain their existing owners. +The native queue retires each pin at the real image opcode or completion; no +additional update clock, combat model or authored ROM program is introduced. +Missing, disabled and cancelled animations reconcile immediately. Repeated +transforms commit in queue order; a replaced slot cannot inherit an old pin. +Transformed front animations get their own native entrance runner. + +Both the native picture layer and the existing source-facing captures use the +selected native image. Placement and image-specific scale metadata are measured +from that same selected image. An explicit false override hides the doll while +preserving its combat HP. A newly created substitute appears at `raisesub`. +The imported Beat Up parameter selects a species index, rather than a party +slot; the existing VM's public `slot` field is retained. Its catalog lookup is +cached for a stable override and catalog. + +The picture tests exercise Gold, Silver, Crystal, Prism and Polished Crystal; +the emitted-image comparison covers both sides in the three retail editions. +They run the real native effects, queue and GSC interpreter. Only device draw +calls are recorded. The pixel suite is also runnable against the previous +engine without adding the new helper, so its failures are wrong emitted +images rather than missing modules. CI runs these tests alongside the existing +object and background owner tests. + +The upstream controller has no source-facing `pic`/`drawPic` boundary yet. +Its tests therefore exercise the native picture layer; optional public-capture +assertions run on the fork where that boundary already exists. The upstream +adaptation uses its existing inline queue admission, not a copied fork queue. + +This change does not qualify every battle picture. Minimize ROM art, complete +GSC doll placement/palette and resize composition, full per-hit Beat Up combat +sequencing, send-out events, natural-move GPU comparisons, Windows and packaged +release validation remain separate work. No supplied mod, protected instance, +ROM or player save is changed. No new release is implied by these CPU tests. diff --git a/src/battle/Gen2PicturePresentation.lua b/src/battle/Gen2PicturePresentation.lua index 8ce55b5e0..a6fc533a4 100644 --- a/src/battle/Gen2PicturePresentation.lua +++ b/src/battle/Gen2PicturePresentation.lua @@ -64,11 +64,12 @@ end -- Native row admission binds the one actual VM (or selects the normal -- disabled/missing-data fallback), never a parallel animation owner. function M.admitted(screen,row,runner) - for i=#(screen._gen2PictureChanges or {}),1,-1 do + local i=1 + while i<=#(screen._gen2PictureChanges or {})do local p=screen._gen2PictureChanges[i] if p.row==row then - if runner then p.runner=runner else remove(screen,i,true) end - end + if runner then p.runner=runner;i=i+1 else remove(screen,i,true)end + else i=i+1 end end end diff --git a/tests/engine/gen2_picture_presentation.lua b/tests/engine/gen2_picture_presentation.lua index 1b322ee25..952c4e004 100644 --- a/tests/engine/gen2_picture_presentation.lua +++ b/tests/engine/gen2_picture_presentation.lua @@ -149,6 +149,35 @@ for _,mode in ipairs({'disabled','missing','cancelled','stopped','replacement'}) if mode~='replacement'then T.eq(b.species,target.mon.species,mode..' commits resolved native visual identity')end T.eq(b.mon.species,species,mode..' retains saved species') end +for _,disabled in ipairs({false,true})do + local s,b,target,row=make('crystal','player','transform') + local initial=s:picImage(b.sprite) + Effects.TRANSFORM_EFFECT(s,b,target) + local middle=b.sprite + local second={anim='PIC_TEST',attackerIsPlayer=true,animDelayed=true} + if not disabled then s.queue[2]=second;s.moveAnimRow=second end + target.species='FIXMON_B' + Effects.TRANSFORM_EFFECT(s,b,target) + local final=b.sprite + T.eq(s:battlerPic(b),initial,'two resolved transforms still show the original queued picture') + if disabled then + s.game.save.options.animations=false + s:updateQueue() + else + s:updateQueue();local r=s.anim;local n=0 + while r.picOverride.player~='transform'and n<120 do s:updateQueue();n=n+1 end + T.check(n<120,'first native transform opcode completes') + T.eq(s:battlerPic(b),s:picImage(middle),'first command reveals only its own queued identity') + endAnim(s) + s:updateQueue();r=s.anim;n=0 + while r.picOverride.player~='transform'and n<120 do s:updateQueue();n=n+1 end + T.check(n<120,'second native transform opcode completes') + end + T.eq(b.species,'FIXMON_B','native admission commits transforms in original queue order') + T.eq(s:battlerPic(b),s:picImage(final),'last resolved visual remains after repeated transforms') + T.eq(#s._gen2PictureChanges,0,'repeated transform pins retire completely') + T.eq(b.mon.species,'FIXMON_A','repeated transforms cannot change the saved species') +end for _,edition in ipairs({'red','emerald','firered'})do Version.set(edition) local s,b=make('crystal','player','dropsub');Version.set(edition) From e61381e06fb0d549fb21017f36596bf9b0c312e5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:10:29 +0100 Subject: [PATCH 088/187] Bind picture pins to the real VM and release replaced programs --- src/battle/BattleState.lua | 2 +- src/battle/Gen2PicturePresentation.lua | 2 +- tests/engine/gen2_picture_presentation.lua | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 1d88b45ec..af96a4435 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3025,7 +3025,7 @@ function BattleState:updateQueue() end if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').admitted(self,item, - self.animPlaying and self.anim or nil) + self.animPlaying and self.animPlayer.runtime or nil) end self.current = nil return true diff --git a/src/battle/Gen2PicturePresentation.lua b/src/battle/Gen2PicturePresentation.lua index a6fc533a4..f37d8eaf3 100644 --- a/src/battle/Gen2PicturePresentation.lua +++ b/src/battle/Gen2PicturePresentation.lua @@ -83,7 +83,7 @@ function M.sync(screen) if not current(screen,p)then discard,apply=true,false elseif p.runner then local value=p.runner.picOverride[side(p.b)] - discard=p.runner.stop or value==p.kind + discard=p.runner~=screen.animPlayer.runtime or p.runner.stop or value==p.kind else local queued=false for _,row in ipairs(screen.queue or {})do if row==p.row then queued=true;break end end diff --git a/tests/engine/gen2_picture_presentation.lua b/tests/engine/gen2_picture_presentation.lua index 952c4e004..5f79f3fb6 100644 --- a/tests/engine/gen2_picture_presentation.lua +++ b/tests/engine/gen2_picture_presentation.lua @@ -128,7 +128,7 @@ end -- Missing/disabled/cancelled programs and a real slot replacement cannot -- leave a pinned picture hanging or repaint the incoming saved Pokemon. -for _,mode in ipairs({'disabled','missing','cancelled','stopped','replacement'})do +for _,mode in ipairs({'disabled','missing','cancelled','stopped','new-program','replacement'})do local s,b,target,row=make('crystal','player','transform') local species=b.mon.species if mode=='disabled'then s.game.save.options.animations=false end @@ -144,6 +144,13 @@ for _,mode in ipairs({'disabled','missing','cancelled','stopped','replacement'}) T.eq(Pictures.species(s,s.player),'FIXMON_B','old transform cannot repaint replacement slot') elseif mode=='stopped'then s:updateQueue();s.anim.stop=true;s:updateQueue() + elseif mode=='new-program'then + s:updateQueue() + local retired=s.anim + s:animForMove('PIC_TEST','enemy') + T.neq(s.anim,retired,'public start creates a distinct real native runner') + s:updateQueue() + T.eq(retired.picOverride.player,nil,'replacing runner does not invent a retired transform opcode') else s:updateQueue()end T.eq(#(s._gen2PictureChanges or {}),0,mode..' releases native picture pin') if mode~='replacement'then T.eq(b.species,target.mon.species,mode..' commits resolved native visual identity')end From e9ec0554af24ca7ca350e5ec984f7889ce7a9b5f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:22:31 +0100 Subject: [PATCH 089/187] Decode native GSC replacement art and honor Crystal staged actor copies --- .../workflows/gen2-picture-presentation.yml | 2 + src/battle/BattleState.lua | 29 ++- src/battle/Gen2AnimData.lua | 5 +- src/battle/Gen2BgPictures.lua | 6 +- src/battle/Gen2PictureArt.lua | 71 +++++++ src/battle/Gen2PicturePresentation.lua | 2 +- src/battle/gen2/AnimRunner.lua | 28 ++- src/import/Gen2BattlePictureArt.lua | 54 ++++++ src/import/RomExtractorGen2.lua | 1 + .../engine/gen2_animation_import_commands.lua | 16 +- tests/engine/gen2_picture_art_draw.lua | 182 ++++++++++++++++++ tests/engine/gen2_picture_art_import.lua | 82 ++++++++ tests/engine/gen2_picture_presentation.lua | 35 +++- tests/gen2_picture_art/fixture.lua | 65 +++++++ 14 files changed, 550 insertions(+), 28 deletions(-) create mode 100644 src/battle/Gen2PictureArt.lua create mode 100644 src/import/Gen2BattlePictureArt.lua create mode 100644 tests/engine/gen2_picture_art_draw.lua create mode 100644 tests/engine/gen2_picture_art_import.lua create mode 100644 tests/gen2_picture_art/fixture.lua diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml index 8c2491985..5a50a9080 100644 --- a/.github/workflows/gen2-picture-presentation.yml +++ b/.github/workflows/gen2-picture-presentation.yml @@ -19,6 +19,8 @@ jobs: run: | luajit tests/engine/gen2_picture_presentation.lua luajit tests/engine/gen2_picture_pixels.lua + luajit tests/engine/gen2_picture_art_import.lua + luajit tests/engine/gen2_picture_art_draw.lua - name: Existing command, object and background owners run: | luajit tests/engine/gen2_object_runtime.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index af96a4435..a16de4671 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -4185,6 +4185,10 @@ function BattleState:pictureSprite(battler) return battler.sprite end +function BattleState:pictureScalePath(battler, path) + return path +end + function BattleState:pictureStill(battler) return self:picImage(self:pictureSprite(battler)) end @@ -11747,7 +11751,8 @@ function BattleState:drawBattlerPicAt(battler, x, y, scale) end end local img = self:battlerPic(battler) - if self:substituteVisible(battler) and not self:fxFaintActive(battler) + local replacement=GameVersion.isGen2() and require('src.battle.Gen2PictureArt').replacement(self,battler)~=nil + if not replacement and self:substituteVisible(battler) and not self:fxFaintActive(battler) and not battler.fainted then self:drawSubstituteDoll(battler) return @@ -11800,7 +11805,7 @@ function BattleState:drawBattlerPicAt(battler, x, y, scale) end return end - if not battler.gen3Transformed + if not replacement and not battler.gen3Transformed and self:drawStadiumBattlerPic(battler, img, x, y, scale) then return end local tr, tg, tb, tc = self:gen3AnimTint(battler) if tc then @@ -12248,6 +12253,10 @@ end -- top-left x, y and the scale (slide/shake offsets are added by the -- caller). Feet stay at 96 for every scale: y + (h - pad) * scale == 96. function BattleState.backPlacement(w, h, pad, padL, scale) + if GameVersion.isGen2() then + return 16 + math.max(0, math.floor((48-w)/2)) + math.floor(w*(1-scale)/2), + 96-h + math.floor(h*(1-scale)), scale + end return 8 - padL * scale, 96 - (h - pad) * scale, scale end @@ -12362,7 +12371,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) love.graphics.setColor(1, 1, 1, 1) local ex, ey = enemyPicXY(img, slide, sx, sy) local s = BattleState.resolveBattleScale(self.data, "front", - imagePathOf(img), self.enemy.mon and self.enemy.mon.species) + self:pictureScalePath(self.enemy,imagePathOf(img)), self.enemy.mon and self.enemy.mon.species) -- EMERALD PLACES ITS POKEMON ON A PLATFORM, and the 7x7 tile slot above -- is the Game Boy's rule for a 160x144 letterbox that has none. On the -- Gen 3 field it left the foe up in the corner well clear of the ground @@ -12492,7 +12501,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) local pad = imagePadBottom[handle] or 0 local padL = imagePadLeft[handle] or 0 local s = BattleState.resolveBattleScale(self.data, "back", - imagePathOf(handle), + self:pictureScalePath(self.player,imagePathOf(handle)), self.player.mon and self.player.mon.species) local gs = self:growInScale(self.player) local ballOut = self:gen3BallFor(self.player) @@ -12503,9 +12512,14 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- pinned, mod scale composed with the grow stage local eff = s * gs if eff > 0 then - love.graphics.draw(img, - 8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx, - 96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff) + if GameVersion.isGen2() then + local dx,dy=BattleState.backPlacement(img:getWidth(),img:getHeight(),pad,padL,eff) + love.graphics.draw(img,dx+sx,dy+sy,0,eff,eff) + else + love.graphics.draw(img, + 8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx, + 96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff) + end end elseif ballOut then -- COMING OUT OF THE BALL, which is the absorb run backwards: the pic @@ -13070,5 +13084,6 @@ require('src.battle.Gen2BattleScene').install(BattleState) -- Compose after native/source view installers, retaining their real draw owners. require('src.battle.Gen2BgPictures').install(BattleState) require('src.battle.Gen2PicturePresentation').install(BattleState) +require('src.battle.Gen2PictureArt').install(BattleState,{load=getImage,palette=monPalette,path=imagePathOf}) return BattleState diff --git a/src/battle/Gen2AnimData.lua b/src/battle/Gen2AnimData.lua index c178162bc..abda1f6a6 100644 --- a/src/battle/Gen2AnimData.lua +++ b/src/battle/Gen2AnimData.lua @@ -3,7 +3,7 @@ -- plus Additional Terms; see compat/gen1-gsc/. No ROM program is invented. local Ids=require('src.battle.Gen2AnimObjectIds') local M={} -M.CACHE_REVISION=1 +M.CACHE_REVISION=2 local function order(constants,key) return constants and constants[key] or Ids[key] end @@ -14,7 +14,8 @@ function M.tables(anims,constants) 'battleAnimFuncOrder','battleAnimGfxOrder','battleAnimObPaletteOrder'})do c[key]=order(constants,key)end c.battleBgEffectOrder=constants and constants.battleBgEffectOrder or require('src.battle.Gen2BgEffectIds').forVersion() - local data={objects={},framesets={},oamsets={},gfx={},palettes=anims.palettes} + local data={objects={},framesets={},oamsets={},gfx={},palettes=anims.palettes, + pictureArt=anims.pictureArt} for id,row in pairs(anims.objects or {})do local name=c.battleAnimObjectOrder[id+1]or id local native=anims.functions and anims.functions[row.fn] diff --git a/src/battle/Gen2BgPictures.lua b/src/battle/Gen2BgPictures.lua index c06a956a7..5b94dd5f2 100644 --- a/src/battle/Gen2BgPictures.lua +++ b/src/battle/Gen2BgPictures.lua @@ -48,6 +48,8 @@ function M.install(Screen) return draw(self,b,x,y,scale) end local side=b.isPlayer and 'player'or'enemy' + local back=b.isPlayer + if self._gen2PicBack~=nil then back=self._gen2PicBack end local state=self:animPicState(side) if state and state.hidden then return end if self.liftedPass and not(state and state.lifted)then return end @@ -56,7 +58,7 @@ function M.install(Screen) if size then local image=self:battlerPic(b) if image then - local factor=size/(b.isPlayer and 6 or 7) + local factor=size/(back and 6 or 7) x=x+math.floor(image:getWidth()*scale*(1-factor)/2) y=y+math.floor(image:getHeight()*scale*(1-factor)) scale=scale*factor @@ -65,7 +67,7 @@ function M.install(Screen) local function paint()withPalette(self,side,function()draw(self,b,x,y,scale)end)end local lifted=state and state.lifted if not lifted then return paint()end - local bandY=(b.isPlayer and 48 or 0)+lifted[1]*8 + local bandY=(back and 48 or 0)+lifted[1]*8 local bandH=lifted[2]*8 local function band(top,height) local g=love.graphics diff --git a/src/battle/Gen2PictureArt.lua b/src/battle/Gen2PictureArt.lua new file mode 100644 index 000000000..03a2f488c --- /dev/null +++ b/src/battle/Gen2PictureArt.lua @@ -0,0 +1,71 @@ +-- Native indexed picture replacements. Full six/seven-tile boxes compose +-- with the existing palette, resize, slide and lifted-row draw owners. +local Version=require('src.core.GameVersion') +local Pictures=require('src.battle.Gen2PicturePresentation') +local M={} +function M.back(screen,b) + if screen._gen2PicBack~=nil then return screen._gen2PicBack end + return b.isPlayer +end +local function record(screen) + local anims=screen.data and screen.data.battle_anims + local art=anims and anims.pictureArt + return art and art.version==1 and art or nil +end +function M.replacement(screen,b,back) + if not(Version.isGen2() and b)then return end + -- Keep the native faint picture owner; a combat substitute cannot replace + -- the Pokemon while that owner's cropped sink is running. + if b.fainted or screen:fxFaintActive(b)then return end + local art=record(screen) + local kind=Pictures.override(screen,b) + local row=art and type(kind)=='string' and art[kind] + if not row then return end + if back==nil then back=M.back(screen,b)end + local path=row[back and 'player'or'enemy'] + local images=screen._gen2PictureImages + if not(type(path)=='string' and images)then return end + local pal=images.palette(screen.data,b.mon.species, + require('src.pokemon.Pokemon').isShiny(b.mon)) + return images.load(path,pal,false),kind +end +function M.install(Screen,images) + Screen._gen2PictureImages=images + local sprite=Screen.pictureSprite + function Screen:pictureSprite(b) + return M.replacement(self,b) or sprite(self,b) + end + local pic=Screen.battlerPic + function Screen:battlerPic(b) + local replacement=M.replacement(self,b) + if replacement then return self:picImage(replacement)end + return pic(self,b) + end + local path=Screen.pictureScalePath + function Screen:pictureScalePath(b,value) + if M.replacement(self,b)then + return images.path(Pictures.sprite(self,b)) or value + end + return path(self,b,value) + end + local doll=Screen.substituteDoll + function Screen:substituteDoll(back) + if not Version.isGen2()then return doll and doll(self,back)end + local art=record(self) + local path=art and art.substitute and art.substitute.sheet + if not path then return end + -- Public source callers own their palette operation. Return indexed art, + -- not an already coloured/register-remapped capture. + local image=images.load(path,nil,false) + if not image then return end + local cached=self._gen2DollQuads + if not(cached and cached.image==image)then + local w,h=image:getDimensions() + cached={image=image,front=love.graphics.newQuad(0,0,16,16,w,h), + back=love.graphics.newQuad(0,16,16,16,w,h)} + self._gen2DollQuads=cached + end + return image,back and cached.back or cached.front + end +end +return M diff --git a/src/battle/Gen2PicturePresentation.lua b/src/battle/Gen2PicturePresentation.lua index f37d8eaf3..2f8c4e2d4 100644 --- a/src/battle/Gen2PicturePresentation.lua +++ b/src/battle/Gen2PicturePresentation.lua @@ -83,7 +83,7 @@ function M.sync(screen) if not current(screen,p)then discard,apply=true,false elseif p.runner then local value=p.runner.picOverride[side(p.b)] - discard=p.runner~=screen.animPlayer.runtime or p.runner.stop or value==p.kind + discard=p.runner~=screen.animPlayer.runtime or p.runner.stopped or value==p.kind else local queued=false for _,row in ipairs(screen.queue or {})do if row==p.row then queued=true;break end end diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua index cc2719412..24228d0c8 100644 --- a/src/battle/gen2/AnimRunner.lua +++ b/src/battle/gen2/AnimRunner.lua @@ -110,6 +110,7 @@ function AnimRunner.new(opts) self.loaded = {} -- Set by the substitute / minimize / transform commands, for the view. self.picOverride = { player = nil, enemy = nil } + self.actorPic = {} self.address = nil self.parent = nil return self @@ -126,6 +127,7 @@ function Runner:start(scriptKey) self.tileDict = {} self.loaded = {} self.picOverride = { player = nil, enemy = nil } + self.actorPic = {} self.address = scriptKey and { key = scriptKey, index = 1 } or nil self.parent = nil return self @@ -269,7 +271,9 @@ C.transform = function(self) -- BattleAnimCmd_Transform: .player loads wTempEnemyMonSpecies into the -- backpic slot, i.e. the player's sprite becomes what it transformed into. local side = self.env.battleTurn == 0 and "player" or "enemy" - self.picOverride[side] = "transform" + local art=self.data.pictureArt + if art and art.stagedActorPic then self.actorPic[side]='transform' + else self.picOverride[side] = "transform" end end C.raisesub = function(self) @@ -325,12 +329,22 @@ C.clearobjs = function(self) self.objects:clearObjs() end C.oamon = function() end C.oamoff = function() end -C.updateactorpic = function() end --- $e7 and $e8-$ed are `ret` on the cart; $f5-$f7 too. $e9 is `minimize`, and --- it really is one of them: BattleAnimCmd_E8 through BattleAnimCmd_ED are six --- labels stacked on a single `ret` (engine/battle_anims/anim_commands.asm). --- The minimize animation that is actually drawn is $e2, minimizeopp above. -C.minimize = function() end +C.updateactorpic = function(self) + local side=self.env.battleTurn==0 and 'player'or'enemy' + local art=self.data.pictureArt + if art and art.stagedActorPic and self.actorPic[side]~=nil then + self.picOverride[side]=self.actorPic[side] + end +end +-- GS $e8/$e9 are ret. Crystal's $e9 fills vTiles0 without repainting the +-- actor; $e8 copies those staged tiles into its BG slot after its wait in +-- the actual Minimize program. The ROM importer declares that capability. +C.minimize = function(self) + local art=self.data.pictureArt + if art and art.stagedMinimize then + self.actorPic[self.env.battleTurn==0 and 'player'or'enemy']='minimize' + end +end C.unknown_e7 = function() end C.unknown_ea = function() end C.unknown_eb = function() end diff --git a/src/import/Gen2BattlePictureArt.lua b/src/import/Gen2BattlePictureArt.lua new file mode 100644 index 000000000..727e5a239 --- /dev/null +++ b/src/import/Gen2BattlePictureArt.lua @@ -0,0 +1,54 @@ +-- GSC GetSubstitutePic / GetMinimizePic, decoded from the mounted ROM. +-- Scratch tiles are column-major on the cartridge; PNG tiles are row-major. +-- No ROM image bytes are shipped with the engine. +local Writer=require('src.import.ImageWriter') +local M={} +local function bytes(e,name,count) + local p=e:symbol(name) + if not p or not e.rom then return end + local ok,raw=pcall(e.rom.bytes,e.rom,p.bank,p.address,count) + if not ok or type(raw)~='table' or #raw~=count then return end + for _,value in ipairs(raw)do + if type(value)~='number' or value%1~=0 or value<0 or value>255 then return end + end + return raw +end +local function box(raw,n,placements) + local out={} + for i=1,n*n*16 do out[i]=0 end + for _,p in ipairs(placements)do + local dest=(p[2]*n+p[1])*16 + for j=1,16 do out[dest+j]=raw[p[3]*16+j]end + end + return Writer.decode2bpp(out,n*8,n*8,true) +end +function M.extract(e) + local doll=bytes(e,'MonsterSpriteGFX',8*16) + local mini=bytes(e,'MinimizePic',16) + local staged=e:symbol('BattleAnimCmd_UpdateActorPic')~=nil + if not(doll or mini or staged)then return end + local record={version=1,stagedActorPic=staged} + if doll then + local front='battle/gen2_substitute_front.png' + local back='battle/gen2_substitute_back.png' + local sheet='battle/gen2_substitute_sheet.png' + e:saveImage(box(doll,7,{{2,5,0},{3,5,1},{2,6,2},{3,6,3}}),front) + e:saveImage(box(doll,6,{{2,4,4},{3,4,5},{2,5,6},{3,5,7}}),back) + e:saveImage(Writer.decode2bpp(doll,16,32,true),sheet) + record.substitute={enemy='assets/generated/'..front, + player='assets/generated/'..back,sheet='assets/generated/'..sheet} + end + if mini then + local front='battle/gen2_minimize_front.png' + local back='battle/gen2_minimize_back.png' + e:saveImage(box(mini,7,{{3,5,0}}),front) + e:saveImage(box(mini,6,{{3,4,0}}),back) + record.minimize={enemy='assets/generated/'..front,player='assets/generated/'..back} + -- GS $e8/$e9 are ret. Crystal stages into vTiles0 before copying to the + -- actor's BG slot; positive symbols also admit supported renamed editions. + record.stagedMinimize=e:symbol('BattleAnimCmd_Minimize')~=nil + and e:symbol('BattleAnimCmd_UpdateActorPic')~=nil + end + return record +end +return M diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 5f0cce32a..02c90e8fd 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -15166,6 +15166,7 @@ function RomExtractorGen2:gen2BattleAnims() return { gen2 = true, commandFormat = "gsc-macro-v1", + pictureArt = require('src.import.Gen2BattlePictureArt').extract(self), scripts = scripts, moveAnims = moveAnims, objects = self:gen2AnimObjects(), diff --git a/tests/engine/gen2_animation_import_commands.lua b/tests/engine/gen2_animation_import_commands.lua index 30803ed7b..633863139 100644 --- a/tests/engine/gen2_animation_import_commands.lua +++ b/tests/engine/gen2_animation_import_commands.lua @@ -44,14 +44,16 @@ for _,edition in ipairs({'gold','silver','crystal'})do end for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do local marker=markerFor(edition) - T.check(marker:find('gen2%-anim%-object%-v1:')~=nil,'generated animation cache has a new contract') + T.check(marker:find('gen2%-anim%-object%-v2:')~=nil,'generated animation cache has a new contract') Version.set(edition) - local old=marker:gsub('gen2%-anim%-object%-v1:','') - love.filesystem.write(Version.cachePrefix(edition)..'rom-cache.complete',old) - local report=Importer.readyReport(edition) - T.eq(report.ok,false,'old handler-labelled cache must be rebuilt') - T.check(report.why and report.why:find('another version',1,true)~=nil, - 'real readiness gate refuses the old format before checking file gaps') + for _,old in ipairs({marker:gsub('gen2%-anim%-object%-v2:',''), + (marker:gsub('gen2%-anim%-object%-v2:','gen2-anim-object-v1:'))})do + love.filesystem.write(Version.cachePrefix(edition)..'rom-cache.complete',old) + local report=Importer.readyReport(edition) + T.eq(report.ok,false,'old commands or missing-picture cache must be rebuilt') + T.check(report.why and report.why:find('another version',1,true)~=nil, + 'real readiness gate refuses old format before checking file gaps') + end end for _,edition in ipairs({'red','emerald','platinum'})do T.check(not markerFor(edition):find('gen2%-anim%-object'), diff --git a/tests/engine/gen2_picture_art_draw.lua b/tests/engine/gen2_picture_art_draw.lua new file mode 100644 index 000000000..5f6711e68 --- /dev/null +++ b/tests/engine/gen2_picture_art_draw.lua @@ -0,0 +1,182 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local F=dofile('tests/gen2_picture_art/fixture.lua') +local Import=require('src.import.Gen2BattlePictureArt') +local Battle=require('src.battle.BattleState') +local V=require('src.core.GameVersion') +local P=require('src.render.PaletteFX') +local G=require('src.render.Gen2PaletteDraw') +local Assets=require('src.render.Assets') +local previous=V.get() +local oldImage,oldData,oldNew,oldDraw=love.image,Assets.imageData,love.graphics.newImage,love.graphics.draw +local graphics=love.graphics +local push,pop,setScissor,getScissor,intersect=graphics.push,graphics.pop,graphics.setScissor, + graphics.getScissor,graphics.intersectScissor +local clip,clipStack=nil,{} +graphics.push=function(...) + clipStack[#clipStack+1]=clip or false + return push(...) +end +graphics.pop=function(...) + clip=table.remove(clipStack)or nil + return pop(...) +end +graphics.getScissor=function()if clip then return unpack(clip)end end +graphics.setScissor=function(x,y,w,h) + clip=x and{x,y,w,h}or nil + return setScissor(x,y,w,h) +end +graphics.intersectScissor=function(x,y,w,h) + if clip then + local right,bottom=math.min(x+w,clip[1]+clip[3]),math.min(y+h,clip[2]+clip[4]) + x,y=math.max(x,clip[1]),math.max(y,clip[2]);w,h=math.max(0,right-x),math.max(0,bottom-y) + end + graphics.setScissor(x,y,w,h) +end +love.image={newImageData=F.image} +local images,calls={},{} +Assets.imageData=function(path) + if images[path]then return images[path]:clone()end + local w=path:find('_back',1,true)and 48 or 56 + local out=F.image(w,w) + out:mapPixel(function(x)return (3-x%4)/3,(3-x%4)/3,(3-x%4)/3,1 end) + return out +end +love.graphics.newImage=function(data) + if type(data)=='table'and data.clone then return data:clone()end + if data=='assets/generated/sprites/monster.png'and images[data]then return images[data]:clone()end + return oldNew(data) +end +love.graphics.draw=function(image,x,y,rotation,sx,sy,extra,...) + if type(x)=='table'and x.getViewport then + x,y,rotation,sx,sy=y,rotation,sx,sy,extra + end + calls[#calls+1]={image=image,x=x,y=y,rotation=rotation,sx=sx or 1,sy=sy or 1, + clip={love.graphics.getScissor()},byte=G.bgp} +end +local A={{255,255,255},{211,41,67},{29,173,101},{3,7,13}} +local function make(edition,side,kind) + V.set(edition);P.setDisplay('gbc',nil);G.setBgp(0xe4) + local e;e,images=F.extractor(edition) + local art=assert(Import.extract(e)) + images['assets/generated/sprites/monster.png']=images[art.substitute.sheet] + local data=T.fixtures.fresh();data.constants.gen=2 + for _,def in pairs(data.pokemon)do def.palette='PIC_ART'end + data.palettes={palettes={PIC_ART=A,GRAYMON=A,MEWMON=A}, + pokemon={FIXMON_A='PIC_ART',FIXMON_B='PIC_ART',FIXMON_C='PIC_ART'}} + require('src.core.Data').publishGen2Palettes(data) + data.moves.PROBE={id='PROBE',name='PROBE',power=0,type='NORMAL',pp=10} + data.battle_anims={gen2=true,pictureArt=art,moveAnims={PROBE=1},scripts={ + [1]={entry=1,code={{op=kind=='substitute'and'raisesub'or'minimizeopp'}, + {op='delay',frames=100},{op='ret'}}}, + }} + Battle.invalidate() + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s.introSlide,s.sendingOut,s.enemySendingOut=0,false,false + s.showPlayerBack,s.showEnemyTrainer=false,false + s:animForMove('PROBE',side);s.animPlayer:update() + return s,s[side],art +end +local function draw(s,b,back) + calls={} + if back==nil or not s.drawPic then s:drawPicsLayer(0,0,0,b.isPlayer and'player'or'enemy',true) + else s:drawPic(b.mon,back)end + return calls +end +local function pixel(image,x,y,color,label) + T.same({image:getPixel(x,y)},{color[1]/255,color[2]/255,color[3]/255,1},label) +end +for _,edition in ipairs({'gold','silver','crystal'})do for _,side in ipairs({'player','enemy'})do + for _,kind in ipairs({'substitute','minimize'})do + local s,b,a=make(edition,side,kind) + local original=b.sprite;local frame=s.anim.frames + local rows=draw(s,b) + T.eq(#rows,1,'native picture layer emits one full ROM box') + local row=rows[1];local n=b.isPlayer and 48 or 56 + T.same({row.image:getDimensions()},{n,n},'real image keeps the six/seven-tile slot') + T.eq(row.x,b.isPlayer and 16 or 96,'actual Gen2 picture box origin') + T.eq(row.y,b.isPlayer and 48 or 0,'actual Gen2 picture box baseline') + local px=kind=='substitute'and 17 or 26 + local py=b.isPlayer and 32 or 40 + pixel(row.image,px,py,A[2],'replacement uses saved Pokemon CGB palette') + local _,_,_,alpha=row.image:getPixel(kind=='substitute'and 16 or 25,py) + T.eq(alpha,0,'background shade remains transparent') + T.eq(b.sprite,original,'replacement does not write native Pokemon image') + T.eq(s.anim.frames,frame,'native drawing does not advance command VM') + if s.pic then + local direct=s:pic(b.mon,b.isPlayer) + T.neq(direct,row.image,'public indexed Pokemon getter retains its original art') + end + if s.drawPic then for _,back in ipairs({false,true})do + rows=draw(s,b,back);T.eq(#rows,1,'public capture emits actual ROM replacement') + row=rows[1];local size=back and 48 or 56 + T.same({row.image:getDimensions()},{size,size},'capture selects requested front/back art') + T.eq(row.x,back and 16 or 96,'public capture correct requested box x') + T.eq(row.y,back and 48 or 0,'public capture correct requested box y') + pixel(row.image,px,back and 32 or 40,A[2],'opposite-side capture retains actual owner palette') + end end + s.anim.bg.slide[side]=5;s.anim.bg.picSize[side]=b.isPlayer and 1 or 4 + rows=draw(s,b);row=rows[1] + local ratio=b.isPlayer and 4/6 or 5/7 + T.eq(row.sx,ratio,'actual resize register scales whole native box') + T.eq(row.x,(b.isPlayer and 16 or 96)+5+math.floor(n*(1-ratio)/2),'slide and resize share selected footprint') + T.eq(row.y,(b.isPlayer and 48 or 0)+math.floor(n*(1-ratio)),'resize retains full picture baseline') + if s.drawPic then + s.anim.bg.picSize[side]=0 + rows=draw(s,b,false);row=rows[1] + T.eq(row.sx,6/7,'front capture uses seven-tile resize denominator even for native player') + T.eq(row.y,math.floor(56*(1-6/7)),'opposite front capture resize pins front baseline') + end + s.anim.bg.picSize[side]=nil;s.anim.bg.slide[side]=0 + s.anim.bg.monShade[side]=0xfc + rows=draw(s,b) + pixel(rows[1].image,px,py,A[4],'per-mon shade register reaches real replacement bake') + T.eq(G.bgp,nil,'native draw restores caller identity-register representation') + s.anim.bg.monShade[side]=nil;G.setBgp(0x1b) + rows=draw(s,b) + pixel(rows[1].image,px,py,A[3],'whole-background BGP reaches real replacement bake') + T.eq(G.bgp,0x1b,'replacement restores caller BGP even when nonidentity') + G.setBgp(0xe4) + do + local base + if s.pic then local _,_,path=s:pic(b.mon,b.isPlayer);base=path + else base=require('src.pokemon.Sprites').path(s.data,b.mon.species,b.isPlayer and 'back'or'front',{mon=b.mon,kind='battle'})end + s.data.battle_sprite_scales={OWNED={path=base,scale=2}} + row=draw(s,b)[1] + T.eq(row.sx,2,'generated picture retains underlying per-image scale override') + s.data.battle_sprite_scales=nil + end + if s.substituteDoll then + local first,quad=s:substituteDoll(true) + T.same({first:getDimensions()},{16,32},'public doll getter exposes genuine indexed ROM sheet') + T.same({quad:getViewport()},{0,16,16,16},'back doll quad selects facing-up frame') + local nextImage,nextQuad=s:substituteDoll(true) + T.eq(nextImage,first,'public doll getter shares native cached image') + T.eq(nextQuad,quad,'public doll getter reuses owned quad') + end + if s.drawPic then + s.anim.bg.liftedRows[side]={4,1} + rows=draw(s,b,false) + T.eq(#rows,2,'normal front capture splits around actual lifted band') + T.eq(rows[1].clip[2],0,'opposite capture lifted band is in front coordinates') + T.eq(rows[1].clip[4],32,'front rows above lifted band remain visible') + T.eq(rows[2].clip[2],40,'front rows below lifted band resume correctly') + s.anim.bg.liftedRows[side]=nil + end + local realDraw=love.graphics.draw + love.graphics.draw=function()error('picture device failure',0)end + T.raises(function()draw(s,b,false)end,'picture device failure','device draw failure remains visible') + T.eq(G.bgp,nil,'failed replacement paint restores caller identity register') + T.eq(s._gen2PicPaletteSide,nil,'failed replacement paint releases palette owner') + love.graphics.draw=realDraw + end +end end +V.set(previous);love.image=oldImage;Assets.imageData=oldData +love.graphics.newImage,love.graphics.draw=oldNew,oldDraw +graphics.push,graphics.pop,graphics.setScissor,graphics.getScissor,graphics.intersectScissor= + push,pop,setScissor,getScissor,intersect +T.finish('native GSC replacement pixels, actual drawing, capture geometry and palette lifetime') diff --git a/tests/engine/gen2_picture_art_import.lua b/tests/engine/gen2_picture_art_import.lua new file mode 100644 index 000000000..88a0a5035 --- /dev/null +++ b/tests/engine/gen2_picture_art_import.lua @@ -0,0 +1,82 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local F=dofile('tests/gen2_picture_art/fixture.lua') +local Art=require('src.import.Gen2BattlePictureArt') +local Data=require('src.battle.Gen2AnimData') +local Player=require('src.battle.Gen2AnimPlayer') +local image=love.image;love.image={newImageData=F.image} +local shades={1,2/3,1/3,0} +for _,edition in ipairs({'gold','silver','crystal','prism'})do + local e,images=F.extractor(edition) + local a=assert(Art.extract(e)) + T.eq(a.version,1,'import advertises actual decoded picture format') + T.eq(a.stagedActorPic,edition=='crystal'or edition=='prism','only positive staged-copy handler is advertised') + local count=0;for _ in pairs(images)do count=count+1 end + T.eq(count,5,'actual importer emits four full boxes and public doll sheet') + for _,back in ipairs({false,true})do + local side=back and 'player'or'enemy' + local n=back and 6 or 7 + local doll=images[a.substitute[side]] + T.same({doll:getDimensions()},{n*8,n*8},'doll uses native full tile box') + for y=0,n*8-1 do for x=0,n*8-1 do + local inside=x>=16 and x<32 and y>=(back and 32 or 40)and y<(back and 48 or 56) + local expected=0 + if inside then + local tile=(back and 4 or 0)+math.floor((y-(back and 32 or 40))/8)*2+math.floor((x-16)/8) + expected=(tile+x%8+2*(y%8))%4 + end + local r,g,b,alpha=doll:getPixel(x,y) + T.same({r,g,b,alpha},{shades[expected+1],shades[expected+1],shades[expected+1],expected==0 and 0 or 1}, + 'ROM doll tile identity, row/column order, shade and transparency') + end end + local mini=images[a.minimize[side]] + for y=0,n*8-1 do for x=0,n*8-1 do + local inside=x>=24 and x<32 and y>=(back and 32 or 40)and y<(back and 40 or 48) + local expected=inside and (3+x%8+2*(y%8))%4 or 0 + local r,g,b,alpha=mini:getPixel(x,y) + T.same({r,g,b,alpha},{shades[expected+1],shades[expected+1],shades[expected+1],expected==0 and 0 or 1}, + 'ROM Minimize tile is copied once into its own native column/row') + end end + end + local tables=Data.tables({pictureArt=a}) + T.eq(tables.pictureArt,a,'native adapter retains actual imported capability and paths') + for _,back in ipairs({false,true})do + for _,kind in ipairs({'minimize','transform'})do + local native={gen2=true,pictureArt=a,moveAnims={PROBE=1},scripts={ + [1]={entry=1,code={{op=kind},{op='delay',frames=3},{op='updateactorpic'}, + {op='delay',frames=3},{op='ret'}}}, + }} + local p=Player.new({battle_anims=native}) + p:start('PROBE',back);local r=p.runtime;local side=back and 'player'or'enemy' + p:update() + local initial + if not a.stagedActorPic and kind=='transform'then initial='transform'end + T.eq(r.picOverride[side],initial, + 'staging cannot repaint actor before update command') + if a.stagedActorPic then T.eq(r.actorPic[side],kind,'real VM owns staged tile identity')end + local steps=0 + while r.frames<5 and steps<100 do p:update();steps=steps+1 end + T.eq(r.picOverride[side],a.stagedActorPic and kind or (kind=='transform'and'transform'or nil), + 'real copy command publishes the staged identity only on capable ROMs') + p:start('PROBE',not back) + T.eq(next(p.runtime.actorPic),nil,'new VM starts with empty staged tiles') + p:release() + end + end +end +for _,change in ipairs({ + function(s)s.MonsterSpriteGFX=nil;s.MinimizePic=nil end, + function(s,r)r[0x5000]=300;r[0x6000]=-1 end, + function(s,r)r[0x5000]=nil;r[0x6000]=nil end, +})do + local e,images=F.extractor('gold',change) + T.eq(Art.extract(e),nil,'missing/malformed ROM bytes do not invent picture art') + T.eq(next(images),nil,'unavailable art cannot leave advertised partial images') +end +local e,images=F.extractor('crystal',function(s)s.MonsterSpriteGFX=nil;s.MinimizePic=nil end) +local a=Art.extract(e) +T.eq(a.stagedActorPic,true,'independent actor-copy capability survives unavailable optional graphics') +T.eq(a.minimize,nil,'missing Minimize symbol does not fabricate a replacement') +T.eq(next(images),nil,'metadata alone emits no invented art') +love.image=image +T.finish('native GSC picture import pixels, staged copy and missing-ROM-data boundaries') diff --git a/tests/engine/gen2_picture_presentation.lua b/tests/engine/gen2_picture_presentation.lua index 5f79f3fb6..9964e74fe 100644 --- a/tests/engine/gen2_picture_presentation.lua +++ b/tests/engine/gen2_picture_presentation.lua @@ -128,11 +128,14 @@ end -- Missing/disabled/cancelled programs and a real slot replacement cannot -- leave a pinned picture hanging or repaint the incoming saved Pokemon. -for _,mode in ipairs({'disabled','missing','cancelled','stopped','new-program','replacement'})do +for _,mode in ipairs({'disabled','missing','cancelled','stopped','no-picture-op','new-program','replacement'})do local s,b,target,row=make('crystal','player','transform') local species=b.mon.species if mode=='disabled'then s.game.save.options.animations=false end if mode=='missing'then s.animPlayer.anims.scripts[1]=nil end + if mode=='no-picture-op'then + s.data.battle_anims.scripts[1].code={{op='delay',frames=2},{op='ret'}} + end Effects.TRANSFORM_EFFECT(s,b,target) if mode=='cancelled'then s:cancelMoveAnim();Pictures.sync(s) elseif mode=='replacement'then @@ -143,7 +146,7 @@ for _,mode in ipairs({'disabled','missing','cancelled','stopped','new-program',' T.eq(s.player.mon,incoming,'native switch retains actual incoming mon') T.eq(Pictures.species(s,s.player),'FIXMON_B','old transform cannot repaint replacement slot') elseif mode=='stopped'then - s:updateQueue();s.anim.stop=true;s:updateQueue() + s:updateQueue();s.anim.stopped=true;s:updateQueue() elseif mode=='new-program'then s:updateQueue() local retired=s.anim @@ -151,11 +154,39 @@ for _,mode in ipairs({'disabled','missing','cancelled','stopped','new-program',' T.neq(s.anim,retired,'public start creates a distinct real native runner') s:updateQueue() T.eq(retired.picOverride.player,nil,'replacing runner does not invent a retired transform opcode') + elseif mode=='no-picture-op'then s:updateQueue();endAnim(s) else s:updateQueue()end T.eq(#(s._gen2PictureChanges or {}),0,mode..' releases native picture pin') if mode~='replacement'then T.eq(b.species,target.mon.species,mode..' commits resolved native visual identity')end T.eq(b.mon.species,species,mode..' retains saved species') end +-- Crystal loads a transformed actor into vTiles0 first; updateactorpic later +-- publishes it. Synthetic old caches/GS keep the previous direct command. +for _,side in ipairs({'player','enemy'})do + local s,b,target,row=make('crystal',side,'transform') + s.data.battle_anims.pictureArt={version=1,stagedActorPic=true} + s.data.battle_anims.scripts[1].code={ + {op='transform'},{op='delay',frames=3},{op='updateactorpic'}, + {op='delay',frames=3},{op='ret'}, + } + local old=s:picImage(b.sprite) + Effects.TRANSFORM_EFFECT(s,b,target) + local replacement=b.sprite + s:updateQueue();local r=s.anim + s:updateQueue() + T.eq(r.actorPic[side],'transform','Crystal stages actual transformed actor tiles') + T.eq(r.picOverride[side],nil,'staging has not repainted the real BG slot') + T.eq(s:battlerPic(b),old,'native picture pin survives the staging command') + local frames=0 + while r.picOverride[side]~='transform'and frames<100 do + T.eq(s:battlerPic(b),old,'all waiting frames retain previous native picture') + s:updateQueue();frames=frames+1 + end + T.check(frames>0 and frames<100,'native copy command is reached') + T.eq(s:battlerPic(b),s:picImage(replacement),'copy command reveals actual transformed actor') + T.eq(b.species,target.mon.species,'shown species commits at actual copy command') + T.eq(#s._gen2PictureChanges,0,'copy command retires the pin exactly once') +end for _,disabled in ipairs({false,true})do local s,b,target,row=make('crystal','player','transform') local initial=s:picImage(b.sprite) diff --git a/tests/gen2_picture_art/fixture.lua b/tests/gen2_picture_art/fixture.lua new file mode 100644 index 000000000..6da1e20d3 --- /dev/null +++ b/tests/gen2_picture_art/fixture.lua @@ -0,0 +1,65 @@ +-- CPU image storage for genuine import/bake pixels, not a drawing substitute. +local F={} +function F.image(w,h) + local out={w=w,h=h,pixels={}} + function out:getWidth()return self.w end + function out:getHeight()return self.h end + function out:getDimensions()return self.w,self.h end + function out:setPixel(x,y,...) + assert(x>=0 and x=0 and y Date: Wed, 28 Oct 2026 04:27:51 +0100 Subject: [PATCH 090/187] Record primary GSC picture-copy behavior and remaining renderer boundaries --- compat/gen1-gsc/provenance.json | 8 +++- docs/native-gen2-picture-art.md | 55 ++++++++++++++++++++++++ docs/native-gen2-picture-presentation.md | 7 +-- src/battle/gen2/AnimRunner.lua | 10 ++--- 4 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 docs/native-gen2-picture-art.md diff --git a/compat/gen1-gsc/provenance.json b/compat/gen1-gsc/provenance.json index 5114142c5..596614a59 100644 --- a/compat/gen1-gsc/provenance.json +++ b/compat/gen1-gsc/provenance.json @@ -24,8 +24,12 @@ "The BG-only paper and clipping subset uses the native indexed palette owner, without requiring the full menu Chrome module on older engines.", "Full object pool uses the already pinned native Gen2AnimMath sine/cosine subset. Native imported numeric animation tables are converted without inventing ROM programs or changing their operands.", "Existing native animation player now uses the complete command interpreter and object pool as its sole clock. The native view consumes emitted OAM through the actual loaded GFX dictionary; no interpolated motion, 48-frame age cap or 16-frame drain remains.", - "The native importer preserves the crossed battler-row macro/handler names. A Gen2-only cache revision rejects old generated commands and preserves game saves." + "The native importer preserves the crossed battler-row macro/handler names. A Gen2-only cache revision rejects old generated commands and preserves game saves.", + "Crystal actor copies now distinguish vTiles0 staging from BG publication. Positive imported symbol capabilities preserve GS dummy commands; this intentionally corrects the pinned reference for Transform and Minimize.", + "ROM-derived full Substitute/Minimize boxes use native indexed image/palette/geometry owners. Opposite captures retain actual battler identity with requested box size. No ROM bytes/images are distributed.", + "Picture pins reconcile the actual interpreter stopped field; programs returning without an image opcode cannot retain stale visuals." ], "licence": "Gen1Recomp GPLv3 with its Additional Terms, retained in LICENSE.MD. Existing authors retain their rights; Roxas2712 integration changes in these derived files are offered on those applicable terms.", - "qualification": "Actual Crystal ROM command/object/BG/OAM comparison against pinned Gen1Recomp535a5ec0: 251 moves and 23 extra programs, both sides, five parameters plus ball/shake/catch cases, 2804 cases and355718 compared frames, no mismatch or observation-limit case. Source e96a8edc; later publication changes only comments, provenance and cache-gate fixture access. Fork a0474fcd passes7893 checks in19 ROM-free groups; independent upstream7ea2eff1 passes5313 in14. These are CPU observations; the old ce12c485 Metal battle predates this VM. New unchanged-client GPU, all picture-override rendering, full send-out/event integration, Windows and packaged release qualification remain open." + "qualification": "Crystal ROM source f5572ee1: fresh native move IDs/GFX/palette/art import,10880 picture pixels independently checked,2804 cases and355718 frames. Exact primary actor staging/publication at frames8/57 replaces picOverride equality only for two known pinned-reference bugs (Transform/Minimize); all other command/OAM/BG observations remain compared. No generated-cache input. Fork CPU source c50da691 passes27 selected groups including shared native picture/Gen3 Substitute regressions; independent upstream e9ec0554 passes10 targeted groups. Publication changes after these sources are documentation/comments; new broad upstream qualification and any GPU/client/Windows/release acceptance remain separate.", + "historicalObjectQualification": "Actual Crystal ROM command/object/BG/OAM comparison against pinned Gen1Recomp535a5ec0: 251 moves and 23 extra programs, both sides, five parameters plus ball/shake/catch cases, 2804 cases and355718 compared frames, no mismatch or observation-limit case. Source e96a8edc; later publication changes only comments, provenance and cache-gate fixture access. Fork a0474fcd passes7893 checks in19 ROM-free groups; independent upstream7ea2eff1 passes5313 in14. These are CPU observations; the old ce12c485 Metal battle predates this VM. New unchanged-client GPU, all picture-override rendering, full send-out/event integration, Windows and packaged release qualification remain open." } diff --git a/docs/native-gen2-picture-art.md b/docs/native-gen2-picture-art.md new file mode 100644 index 000000000..44179a867 --- /dev/null +++ b/docs/native-gen2-picture-art.md @@ -0,0 +1,55 @@ +# GSC replacement art and Crystal actor copies + +The native importer reads `MonsterSpriteGFX` and `MinimizePic` from the mounted +ROM. It writes the two Substitute and two Minimize pictures in full 6x6/7x7 +tile boxes, plus the indexed Substitute sheet for the public getter. Cartridge +scratch tiles are column-major; generated PNG tiles are row-major. No ROM +image is committed or published with this contribution. Missing symbols or +invalid byte ranges do not fabricate art. + +Native drawing uses these images through its existing image/palette cache. +The full picture footprint composes with slide, resize and lifted-row owners; +Substitute no longer bypasses those operations through a fixed Gen1 doll draw. +Minimize cannot turn into a Stadium model. The native faint picture retains +its owner. Gen2 backs use the 6x6 box at (16,48), including trainer and grow-in +placement, rather than Gen1's left-padding compensation. Image-specific scale +settings still refer to the underlying Pokemon image. Opposite-orientation +captures use the requested box dimensions with the real battler's palette. + +The public `pic` getter keeps its original indexed-Pokemon contract. Its +`drawPic` capture paints the current replacement. `substituteDoll` exposes +the actual indexed 16x32 ROM sheet with cached front/back quads, allowing the +source caller to own its palette operation. + +Crystal differs from Gold/Silver: Transform and Minimize stage actor tiles in +vTiles0; `updateactorpic` later copies them into the actor's BG slot. The pinned +Gen1 reference's immediate Transform and empty Minimize/updateactorpic handlers +missed this distinction. Positive ROM symbols advertise these capabilities; +Gold/Silver retain their direct Transform and dummy $e8/$e9 commands. The same +existing VM owns staging, delays and publication. The native pin reconciler +also reads that VM's actual `stopped` field, including a program returning +without an image opcode. No additional animation or combat clock is added. + +The primary definitions are in [Crystal's commands](https://github.com/pret/pokecrystal/blob/master/engine/battle_anims/anim_commands.asm), +[Crystal's programs](https://github.com/pret/pokecrystal/blob/master/data/moves/animations.asm) +and [Gold/Silver's commands](https://github.com/pret/pokegold/blob/master/engine/battle_anims/anim_commands.asm). +The Gen2-only animation cache revision is now 2 so an existing v1 cache gets +the new art/capabilities on import; other generations and saves retain their +existing contracts. Renamed editions advertise only data their symbols expose. + +ROM-free tests check every decoded pixel, real native drawing, palettes, +requested capture geometry, resize/lifted composition, image-scale settings, +error restoration and native terminal ownership. CPU checks with the supplied +Crystal ROM independently validate 10,880 picture pixels against its bitplanes, +then exercise 2,804 cases and 355,718 frames. Crystal's actor staging is checked +at VM frame 8 and publication at frame 57 against the actual imported programs. +For Transform/Minimize only, picture-override equality with the buggy pinned +reference is replaced by that primary-source oracle; all other command/object, +OAM and BG observations remain compared. No old generated-cache input is used. + +These checks do not establish GPU, unchanged-mod, Windows or release parity. +The native OAM drawer still skips battler-row pseudo-sheets, as does the pinned +reference; that missing renderer path needs separate work. Complete send-out, +identity/EXP/capture/menu events, model/download options, the full native battle +factory/turn API and broader release qualification also remain open. Mods, +protected instances, ROM originals and player saves are unchanged. diff --git a/docs/native-gen2-picture-presentation.md b/docs/native-gen2-picture-presentation.md index 18f75f80d..a3d13ea1d 100644 --- a/docs/native-gen2-picture-presentation.md +++ b/docs/native-gen2-picture-presentation.md @@ -36,8 +36,9 @@ Its tests therefore exercise the native picture layer; optional public-capture assertions run on the fork where that boundary already exists. The upstream adaptation uses its existing inline queue admission, not a copied fork queue. -This change does not qualify every battle picture. Minimize ROM art, complete -GSC doll placement/palette and resize composition, full per-hit Beat Up combat -sequencing, send-out events, natural-move GPU comparisons, Windows and packaged +The follow-up [native picture-art implementation](native-gen2-picture-art.md) +adds Minimize/Substitute ROM art, GSC placement/palette/resize composition and +Crystal's staged actor copy. Full per-hit Beat Up combat sequencing, battler-row +OAM drawing, send-out events, natural-move GPU comparisons, Windows and packaged release validation remain separate work. No supplied mod, protected instance, ROM or player save is changed. No new release is implied by these CPU tests. diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua index 24228d0c8..446022039 100644 --- a/src/battle/gen2/AnimRunner.lua +++ b/src/battle/gen2/AnimRunner.lua @@ -262,11 +262,9 @@ end -- The commands that swap a battler's pic out for something else. The port -- records which, and the view draws it. -- --- Every one of them branches on hBattleTurn the same way --- (engine/battle_anims/anim_commands.asm): `and a / jr z, .player`, and the --- .player arm is the one that writes vTiles2 tile $31, the 6x6 BACKPIC slot. --- So turn 0, the player attacking, always repaints the PLAYER's own pic, and --- the fall-through arm (tile $00, the 7x7 frontpic) repaints the enemy's. +-- hBattleTurn selects the actor's own 6x6 back or 7x7 front slot. GS writes +-- Transform directly to BG; Crystal stages it in vTiles0 and copies it at +-- updateactorpic. Keep that distinction at the command owner. C.transform = function(self) -- BattleAnimCmd_Transform: .player loads wTempEnemyMonSpecies into the -- backpic slot, i.e. the player's sprite becomes what it transformed into. @@ -295,7 +293,7 @@ C.minimizeopp = function(self) end C.beatup = function(self) - -- wBattleAnimParam is the party slot whose pic to show. + -- wBattleAnimParam is the species byte; `slot` is the retained public key. local side = self.env.battleTurn == 0 and "player" or "enemy" self.picOverride[side] = { kind = "beatup", slot = self.param } end From 80a33201e6d4e7f08366970912a870fe7ca2c708 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:29:07 +0100 Subject: [PATCH 091/187] Clarify which public capture boundaries are provided by upstream --- docs/native-gen2-picture-art.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/native-gen2-picture-art.md b/docs/native-gen2-picture-art.md index 44179a867..24513d8b4 100644 --- a/docs/native-gen2-picture-art.md +++ b/docs/native-gen2-picture-art.md @@ -16,8 +16,9 @@ placement, rather than Gen1's left-padding compensation. Image-specific scale settings still refer to the underlying Pokemon image. Opposite-orientation captures use the requested box dimensions with the real battler's palette. -The public `pic` getter keeps its original indexed-Pokemon contract. Its -`drawPic` capture paints the current replacement. `substituteDoll` exposes +Where the public `pic`/`drawPic` boundary already exists, the getter keeps its +original indexed-Pokemon contract and the capture paints the current replacement. +The upstream adaptation does not invent those missing methods. `substituteDoll` exposes the actual indexed 16x32 ROM sheet with cached front/back quads, allowing the source caller to own its palette operation. From cead22a6fdeca109fce60a36e4905f8be643b281 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:54:33 +0100 Subject: [PATCH 092/187] Render native GSC battler rows from command-time picture snapshots --- .../workflows/gen2-picture-presentation.yml | 1 + docs/native-gen2-battler-rows.md | 43 +++++ src/battle/BattleState.lua | 4 +- src/battle/Gen2AnimPlayer.lua | 3 + src/battle/Gen2AnimView.lua | 28 ++- src/battle/Gen2BattlerRows.lua | 50 ++++++ src/battle/Gen2PictureArt.lua | 13 ++ src/battle/gen2/AnimRunner.lua | 3 + tests/engine/gen2_battler_rows.lua | 164 ++++++++++++++++++ 9 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 docs/native-gen2-battler-rows.md create mode 100644 src/battle/Gen2BattlerRows.lua create mode 100644 tests/engine/gen2_battler_rows.lua diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml index 5a50a9080..9a68670e4 100644 --- a/.github/workflows/gen2-picture-presentation.yml +++ b/.github/workflows/gen2-picture-presentation.yml @@ -21,6 +21,7 @@ jobs: luajit tests/engine/gen2_picture_pixels.lua luajit tests/engine/gen2_picture_art_import.lua luajit tests/engine/gen2_picture_art_draw.lua + luajit tests/engine/gen2_battler_rows.lua - name: Existing command, object and background owners run: | luajit tests/engine/gen2_object_runtime.lua diff --git a/docs/native-gen2-battler-rows.md b/docs/native-gen2-battler-rows.md new file mode 100644 index 000000000..0ade5a64d --- /dev/null +++ b/docs/native-gen2-battler-rows.md @@ -0,0 +1,43 @@ +# Native GSC battler rows + +Contributions by Roxas2712. Existing GSC interpreter and object-drawing +provenance and licences remain in `compat/gen1-gsc/` and `NOTICE.md`. + +The real interpreter already registers the player-head/enemy-feet pseudo +sheets. The renderer previously rejected every loaded entry carrying a +`battler` field, so its genuine OAM entries had no pixels to draw. The frozen +Gen1 reference also skips these entries; VM-state equality alone does not +qualify this drawing path. + +At the existing `battlergfx` command the native player now snapshots the +actual screen-owned indexed picture into one/two tile rows. Enemy feet come +from the bottom of the seven-tile front slot; player head comes from the top +of the six-tile back slot. Five/six-tile native fronts have the cartridge's +one-tile left padding. The VM retains its actual command timing, descriptors, +OAM positions, flips and palettes. No second interpreter or battle state is +introduced. Source image data is shared/read-only; copying performs no GPU +readback. Repeated drawing does not decode or upload images. + +OBJ colour zero is removed before palette application, independently of BG +register changes. Custom RGB art keeps its alpha and opts out of indexed +recolouring. Crystal crop descriptors select a single frame. Replacement +art and queued Transform use the existing picture presentation authority; +reading the source never reveals a queued future picture. + +Copied images belong to the native view, keyed weakly by the actual loaded +descriptor. Retaining an old runner retains its copied picture; a restarted +animation cannot substitute its current image into old OAM. View retirement +releases remaining images. No mod, ROM, player save or Gen1 installation is +changed. No extracted ROM pixels are committed. + +Primary source: + +- [Crystal battlergfx copy commands](https://github.com/pret/pokecrystal/blob/master/engine/battle_anims/anim_commands.asm) +- [Native front-picture padding](https://github.com/pret/pokecrystal/blob/master/engine/gfx/load_pics.asm) + +`tests/engine/gen2_battler_rows.lua` drives actual native battles, commands +and OAM, checking copied pixel values, column-major strip addressing, +attacker mirroring, both flips, palette uploads, read-only drawing, retained +runners and failure cleanup in Gold/Silver/Crystal. It also checks native +front sizes and custom RGB/crop input. This is CPU/device-record validation, +not a new GPU, full unchanged-client, Windows or release qualification. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index a16de4671..2ea25e83c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1259,6 +1259,7 @@ local function newBattle(game) if ok then self.animPlayer = Gen2AnimPlayer.new(game.data) self.animPlayer.battle = self.battle + self.animPlayer.screen = self self.animView = self.animPlayer.view self.anims = self.animPlayer.anims end @@ -13084,6 +13085,7 @@ require('src.battle.Gen2BattleScene').install(BattleState) -- Compose after native/source view installers, retaining their real draw owners. require('src.battle.Gen2BgPictures').install(BattleState) require('src.battle.Gen2PicturePresentation').install(BattleState) -require('src.battle.Gen2PictureArt').install(BattleState,{load=getImage,palette=monPalette,path=imagePathOf}) +require('src.battle.Gen2PictureArt').install(BattleState,{load=getImage,palette=monPalette,path=imagePathOf, + source=function(image)return imageMeta[image]end}) return BattleState diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index f3612b662..4d89cc977 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -130,6 +130,9 @@ function Gen2AnimPlayer:start(moveId,attackerIsPlayer,opts) param=param,ballPalette=options.ballPalette,sgb=options.sgb, flying=options.flying, hooks={ + battlerGfx=function(runner,front,back) + self.view:loadBattlerRows(self.screen,front,back) + end, pokeballWobble=function() wobbles=wobbles+1 return wobbles<=(options.shakes or 0)and 0 or options.caught and 1 or 2 diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua index 2225433a6..25dfd3dde 100644 --- a/src/battle/Gen2AnimView.lua +++ b/src/battle/Gen2AnimView.lua @@ -5,6 +5,7 @@ -- compat/gen1-gsc/LICENSE.MD; inherited object-drawing rights remain intact. local Palette = require('src.render.Gen2PaletteDraw') local PaletteFX = require('src.render.PaletteFX') +local BattlerRows = require('src.battle.Gen2BattlerRows') local View = {} View.__index = View local slots = { @@ -13,7 +14,14 @@ local slots = { PAL_BATTLE_OB_BLUE=6, PAL_BATTLE_OB_BROWN=7, } function View.new(anims, data) - return setmetatable({data=anims or {}, gameData=data or {}, images={}, quads={}}, View) + return setmetatable({data=anims or {}, gameData=data or {}, images={}, quads={}, + battlerRows=setmetatable({},{__mode='k'})}, View) +end +function View:loadBattlerRows(screen,...) + if not(screen and screen.gen2ObjectPictureSource)then return end + for _,entry in ipairs({...})do + self.battlerRows[entry]=BattlerRows.snapshot(screen:gen2ObjectPictureSource(entry.battler),entry) + end end function View:image(path) if not path then return nil end @@ -29,6 +37,10 @@ function View:release() if image and image.release then image:release() end end self.images, self.quads = {}, {} + for _,snapshot in pairs(self.battlerRows)do + if snapshot.image.release then snapshot.image:release()end + end + self.battlerRows=setmetatable({},{__mode='k'}) self:releaseBg() end function View:objPalette(name, battle) @@ -60,7 +72,7 @@ function View:drawObjects(runner, battle) g.push('all') local ok, err = pcall(function() g.setColor(1,1,1,1) - local active,activeByte + local active,activeByte,activeRaw for _, obj in ipairs(runner:oam()) do local entry,index for i=#runner.loaded,1,-1 do @@ -71,15 +83,17 @@ function View:drawObjects(runner, battle) end end local sheet=entry and not entry.battler and self.data.gfx and self.data.gfx[entry.gfx] - local image=sheet and self:image(sheet.image) - local quad=image and self:quad(entry.gfx,index,image) + local snapshot=entry and self.battlerRows[entry] + local image=snapshot and snapshot.image or sheet and self:image(sheet.image) + local quad=snapshot and BattlerRows.quad(snapshot,index)or image and self:quad(entry.gfx,index,image) if quad then local colors = self:objPalette(obj.palette, battle) local slot=slots[obj.palette]or tonumber(obj.palette) local byte=(slot==2 or slot==3)and runner.bg and runner.bg.obp0 or nil - if colors~=active or byte~=activeByte then - if not Palette.useRaw(Palette.remap(Palette.resolve(colors),byte))then g.setShader()end - active,activeByte=colors,byte + local raw=snapshot and snapshot.trueColor or false + if colors~=active or byte~=activeByte or raw~=activeRaw then + if raw or not Palette.useRaw(Palette.remap(Palette.resolve(colors),byte))then g.setShader()end + active,activeByte,activeRaw=colors,byte,raw end local attr = obj.attr or 0 local sx = math.floor(attr/32)%2 == 1 and -1 or 1 diff --git a/src/battle/Gen2BattlerRows.lua b/src/battle/Gen2BattlerRows.lua new file mode 100644 index 000000000..9128b7692 --- /dev/null +++ b/src/battle/Gen2BattlerRows.lua @@ -0,0 +1,50 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- GSC copies enemy feet / player head from BG picture tiles into OBJ VRAM. +-- Snapshot at the real battlergfx command; drawing never rereads live art. +local Assets=require('src.render.Assets') +local M={} +function M.snapshot(source,entry) + if not(source and source.path and love.image and love.image.newImageData)then return end + local raw=Assets.imageDataShared(source.path) + local w,h=raw:getDimensions() + local x0=0 + if source.crop then w=source.crop.width;x0=(source.crop.index-1)*w end + if w<=0 or h<=0 then return end + local back=entry.battler=='player' + local size=back and 48 or 56 + -- Native fronts are 5/6/7 tiles. PadFrontpic puts both smaller sizes + -- one tile from the left and bottom-aligns them in the seven-tile box. + -- Replacement sheets already contain their full box. Custom larger art + -- is sampled into the same logical OBJ cells, preserving its own alpha. + local lw,lh=size,size + if not back and (w==40 or w==48)then lw=w end + if not back and (h==40 or h==48)then lh=h end + local left=lw=0 and py>=0 and px0.83 then a=0 end + out:setPixel(x,y,r,g,b,a) + end + end end + local image=love.graphics.newImage(out) + if out.release then out:release()end + image:setFilter('nearest','nearest') + return {image=image,rows=rows,cols=cols,trueColor=source.trueColor,quads={}} +end +function M.quad(snapshot,index) + if index<0 or index>=snapshot.rows*snapshot.cols then return end + if not snapshot.quads[index]then + snapshot.quads[index]=love.graphics.newQuad(math.floor(index/snapshot.rows)*8, + (index%snapshot.rows)*8,8,8,snapshot.cols*8,snapshot.rows*8) + end + return snapshot.quads[index] +end +return M diff --git a/src/battle/Gen2PictureArt.lua b/src/battle/Gen2PictureArt.lua index 03a2f488c..40aa9e4e9 100644 --- a/src/battle/Gen2PictureArt.lua +++ b/src/battle/Gen2PictureArt.lua @@ -31,6 +31,19 @@ function M.replacement(screen,b,back) end function M.install(Screen,images) Screen._gen2PictureImages=images + function Screen:gen2ObjectPictureSource(side) + if not Version.isGen2()then return end + local b=self[side] + if not b then return end + local art=record(self) + local kind=Pictures.override(self,b) + local row=art and type(kind)=='string'and art[kind] + local path=row and row[side] + if path then return {path=path}end + local image=Pictures.sprite(self,b) + local meta=images.source(image) + if meta then return {path=meta.path,crop=meta.crop,trueColor=meta.trueColor}end + end local sprite=Screen.pictureSprite function Screen:pictureSprite(b) return M.replacement(self,b) or sprite(self,b) diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua index 446022039..800f01964 100644 --- a/src/battle/gen2/AnimRunner.lua +++ b/src/battle/gen2/AnimRunner.lua @@ -216,6 +216,9 @@ function Runner:loadBattlerGfx(rows) self.loaded[#self.loaded + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 6, battler = "player", rows = rows } + if self.hooks.battlerGfx then + self.hooks.battlerGfx(self,self.loaded[#self.loaded-1],self.loaded[#self.loaded]) + end end -------------------------------------------------------------------------- diff --git a/tests/engine/gen2_battler_rows.lua b/tests/engine/gen2_battler_rows.lua new file mode 100644 index 000000000..93c3358c0 --- /dev/null +++ b/tests/engine/gen2_battler_rows.lua @@ -0,0 +1,164 @@ +-- Actual native command -> copied picture cells -> emitted OBJ draw calls. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local F=dofile('tests/gen2_picture_art/fixture.lua') +local V=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Assets=require('src.render.Assets') +local P=require('src.render.Gen2PaletteDraw') +local Rows=require('src.battle.Gen2BattlerRows') +local oldVersion=V.get() +local g=love.graphics +local oldData,oldImage,oldNew,oldDraw,oldShader=Assets.imageData,love.image,g.newImage,g.draw,g.newShader +local sources,draws,decodes,uploads={},{},0,0 +love.image={newImageData=F.image} +local function picture(w,h,seed) + local image=F.image(w,h) + image:mapPixel(function(x,y) + local shade=(math.floor(x/8)*3+math.floor(y/8)*2+x+2*y+seed)%4 + return (3-shade)/3,(3-shade)/3,(3-shade)/3,1 + end) + return image +end +Assets.imageData=function(path) + decodes=decodes+1 + if not sources[path]then sources[path]=picture(path:find('/back/',1,true)and 48 or 40, + path:find('/back/',1,true)and 48 or 40,0)end + return sources[path]:clone() +end +g.newImage=function(data) + if type(data)=='table'and data.clone then + uploads=uploads+1 + local image=data:clone() + function image:release()self.released=true end + return image + end + return oldNew(data) +end +g.newShader=function()return {values={},send=function(self,k,v)self.values[k]=v end}end +g.draw=function(image,quad,x,y,angle,sx,sy) + local shader=g.getShader();local colors={} + if shader and shader.values then for k,v in pairs(shader.values)do colors[k]=v end end + draws[#draws+1]={image=image,quad=quad,x=x,y=y,sx=sx,sy=sy,colors=colors} +end +local function make(edition,rows,attacker) + V.set(edition);Assets.invalidate();Battle.invalidate();sources={} + local data=T.fixtures.fresh();data.constants.gen=2 + data.constants.battleAnimGfxOrder={'BATTLE_ANIM_GFX_PLAYERHEAD','BATTLE_ANIM_GFX_ENEMYFEET'} + data.battle_anims={gen2=true,moveAnims={PROBE=1},scripts={[1]={entry=1,code={ + {op=rows==2 and 'battlergfx_1row'or'battlergfx_2row'}, + {op='obj',args={0,64,80,0}},{op='obj',args={1,88,88,0}}, + {op='delay',frames=100},{op='ret'}}}}, + functions={[0]='MoveFromUserToTarget'},objects={},framesets={},oam={}} + for id=0,1 do + data.battle_anims.objects[id]={oamFlags=1,fixY=255,fn=0,frameset=id,palette=id,gfx=id} + data.battle_anims.framesets[id]={frames={{oam=id,duration=1}},loop='restart'} + local sprites={} + for tile=0,(id==0 and 7 or 6)*rows-1 do + sprites[#sprites+1]={x=0,y=0,tile=tile,attr=(tile%4)*32} + end + data.battle_anims.oam[id]={tile=0,sprites=sprites} + end + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s:animForMove('PROBE',attacker) + local p=s.animPlayer + T.eq(p.screen,s,'existing native player owns the real picture source') + p:update() + return s,p +end +for _,edition in ipairs{'gold','silver','crystal'}do + for _,rows in ipairs{1,2}do for _,attacker in ipairs{'player','enemy'}do + local s,p=make(edition,rows,attacker) + local runner=p.runtime + T.eq(#runner.loaded,2) + for _,entry in ipairs(runner.loaded)do + local snapshot=p.view.battlerRows[entry] + T.check(snapshot~=nil,'real battlergfx command uploads its native battler rows') + local back=entry.battler=='player' + local n=back and 48 or 56 + T.same({snapshot.image:getDimensions()},{n,rows*8}) + local raw=sources[s:gen2ObjectPictureSource(entry.battler).path] + for y=0,rows*8-1 do for x=0,n-1 do + local px,py=x,back and y or 56-rows*8+y + if not back then px=px-8;py=py-(56-raw.h)end + local expected={0,0,0,0} + if px>=0 and py>=0 and px.83 then expected[4]=0 end + end + T.same({snapshot.image:getPixel(x,y)},expected,'OBJ pixels follow native BG-to-OBJ copy and colour-zero key') + end end + end + local before,frame,random=decodes,p.frames,s.rng + local images=uploads + draws={};local outside={};g.setShader(outside);P.setBgp(0) + p:draw();local once=draws + T.eq(#once,13*rows,'both real picture strips reach the native OAM renderer') + T.eq(g.getShader(),outside,'caller shader restored') + local oam=runner:oam() + for index,draw in ipairs(once)do + local obj=oam[index] + local entry=runner.loaded[index<=7*rows and 1 or 2] + local localTile=obj.tile-entry.tile + T.same({draw.quad:getViewport()},{math.floor(localTile/rows)*8,(localTile%rows)*8,8,8}, + 'copied strip is addressed column-major, not as a regular OBJ atlas') + local sx=math.floor(obj.attr/32)%2==1 and -1 or 1 + local sy=math.floor(obj.attr/64)%2==1 and -1 or 1 + T.same({draw.x,draw.y,draw.sx,draw.sy},{obj.x-8+(sx<0 and 8 or 0),obj.y-16+(sy<0 and 8 or 0),sx,sy}, + 'actual native OAM retains both flips and attacker mirroring') + local colors=p.view:objPalette(obj.palette,p.battle) + local expected=P.resolve(colors) + for i=1,4 do T.same(draw.colors['pal'..(i-1)],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255}, + 'OBJ colours retain the real battler palette and ignore BGP')end + end + -- Mutating the picture provider cannot repaint already copied OBJ VRAM. + local source=s:gen2ObjectPictureSource('enemy') + sources[source.path]=picture(40,40,1) + for _=1,4 do p:draw()end + T.eq(decodes,before,'drawing does no image decode or GPU readback') + T.eq(uploads,images,'drawing does not upload images again') + T.eq(p.frames,frame,'drawing never runs the VM') + T.eq(s.rng,random) + local first=runner.loaded[1];local snapshot=p.view.battlerRows[first] + p:start('PROBE',attacker=='player');p:update() + T.check(p.runtime~=runner,'restart still creates the existing distinct runner') + T.eq(p.view.battlerRows[first],snapshot,'retained old runner keeps its own copied art') + local retained=runner:oam();retained.runner=runner;draws={};p:drawSprites(retained) + T.eq(#draws,13*rows,'retained real OAM draws without rebinding to the current runner') + T.eq(draws[1].image,snapshot.image) + local draw=g.draw;g.draw=function()g.setShader({});error('row draw failure')end + T.raises(function()p:drawSprites(retained)end,'row draw failure') + T.eq(g.getShader(),outside,'failed drawing also restores caller graphics state') + g.draw=draw + p:release();T.eq(snapshot.image.released,true,'view retirement releases retained snapshot images') + P.setBgp(nil);g.setShader() + end end +end +-- Smaller 6x6 fronts still start one TILE from the left, as PadFrontpic does. +for _,n in ipairs{40,48,56}do + sources['sized-'..n]=picture(n,n,0);Assets.invalidate() + local row=Rows.snapshot({path='sized-'..n},{battler='enemy',rows=1}) + local left=n<56 and 8 or 0 + for x=0,55 do + local expected={0,0,0,0} + if x>=left and x.83 then expected[4]=0 end + end + T.same({row.image:getPixel(x,7)},expected,'declared native 5/6/7-tile front padding') + end +end +-- Custom RGB art opts out of indexed colour-zero removal; crop keeps the +-- selected Crystal frame instead of sampling an entire animation strip. +Assets.invalidate();sources.rgb=F.image(96,48) +sources.rgb:mapPixel(function(x)return x<48 and 0 or 1,.4,.2,.75 end) +local rgb=Rows.snapshot({path='rgb',trueColor=true,crop={index=2,width=48}},{battler='player',rows=2}) +T.same({rgb.image:getPixel(0,0)},{1,.4,.2,.75});T.eq(rgb.trueColor,true) +T.eq(Rows.quad(rgb,12),nil,'out-of-range OAM cells are not invented') +Assets.imageData,love.image,g.newImage,g.draw,g.newShader=oldData,oldImage,oldNew,oldDraw,oldShader +Assets.invalidate();V.set(oldVersion) +T.finish('native GSC battler-row copy pixels, actual OAM, palettes, snapshots and lifetime') From 36e4d7ca37b49c4d927a46d976d27b0f9d4901df Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:56:09 +0100 Subject: [PATCH 093/187] Use explicit native front and back image geometry in row fixtures --- tests/engine/gen2_battler_rows.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/engine/gen2_battler_rows.lua b/tests/engine/gen2_battler_rows.lua index 93c3358c0..2aad63ce6 100644 --- a/tests/engine/gen2_battler_rows.lua +++ b/tests/engine/gen2_battler_rows.lua @@ -44,6 +44,10 @@ end local function make(edition,rows,attacker) V.set(edition);Assets.invalidate();Battle.invalidate();sources={} local data=T.fixtures.fresh();data.constants.gen=2 + for name,def in pairs(data.pokemon)do + def.spriteFront='qa/front/'..name..'.png' + def.spriteBack='qa/back/'..name..'.png' + end data.constants.battleAnimGfxOrder={'BATTLE_ANIM_GFX_PLAYERHEAD','BATTLE_ANIM_GFX_ENEMYFEET'} data.battle_anims={gen2=true,moveAnims={PROBE=1},scripts={[1]={entry=1,code={ {op=rows==2 and 'battlergfx_1row'or'battlergfx_2row'}, From 81a46477b12fd2ac855250041b467d04ad665eed Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:57:33 +0100 Subject: [PATCH 094/187] Verify queued and replacement art through real native row copies --- docs/native-gen2-battler-rows.md | 6 +++-- src/battle/Gen2BattlerRows.lua | 4 ++- tests/engine/gen2_battler_rows.lua | 41 ++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/native-gen2-battler-rows.md b/docs/native-gen2-battler-rows.md index 0ade5a64d..38ed54048 100644 --- a/docs/native-gen2-battler-rows.md +++ b/docs/native-gen2-battler-rows.md @@ -15,8 +15,10 @@ from the bottom of the seven-tile front slot; player head comes from the top of the six-tile back slot. Five/six-tile native fronts have the cartridge's one-tile left padding. The VM retains its actual command timing, descriptors, OAM positions, flips and palettes. No second interpreter or battle state is -introduced. Source image data is shared/read-only; copying performs no GPU -readback. Repeated drawing does not decode or upload images. +introduced. Source image data is shared/read-only where the native shared +decoder is available; older upstream uses its existing owned decoder once +per copy. Copying performs no GPU readback. Repeated drawing does not decode +or upload images. OBJ colour zero is removed before palette application, independently of BG register changes. Custom RGB art keeps its alpha and opts out of indexed diff --git a/src/battle/Gen2BattlerRows.lua b/src/battle/Gen2BattlerRows.lua index 9128b7692..da2ccc89e 100644 --- a/src/battle/Gen2BattlerRows.lua +++ b/src/battle/Gen2BattlerRows.lua @@ -5,7 +5,8 @@ local Assets=require('src.render.Assets') local M={} function M.snapshot(source,entry) if not(source and source.path and love.image and love.image.newImageData)then return end - local raw=Assets.imageDataShared(source.path) + local shared=Assets.imageDataShared + local raw=(shared or Assets.imageData)(source.path) local w,h=raw:getDimensions() local x0=0 if source.crop then w=source.crop.width;x0=(source.crop.index-1)*w end @@ -36,6 +37,7 @@ function M.snapshot(source,entry) end end local image=love.graphics.newImage(out) if out.release then out:release()end + if not shared and raw.release then raw:release()end image:setFilter('nearest','nearest') return {image=image,rows=rows,cols=cols,trueColor=source.trueColor,quads={}} end diff --git a/tests/engine/gen2_battler_rows.lua b/tests/engine/gen2_battler_rows.lua index 2aad63ce6..1c4c79f8c 100644 --- a/tests/engine/gen2_battler_rows.lua +++ b/tests/engine/gen2_battler_rows.lua @@ -163,6 +163,47 @@ sources.rgb:mapPixel(function(x)return x<48 and 0 or 1,.4,.2,.75 end) local rgb=Rows.snapshot({path='rgb',trueColor=true,crop={index=2,width=48}},{battler='player',rows=2}) T.same({rgb.image:getPixel(0,0)},{1,.4,.2,.75});T.eq(rgb.trueColor,true) T.eq(Rows.quad(rgb,12),nil,'out-of-range OAM cells are not invented') +do + local s,p=make('crystal',2,'player') + local original=s:gen2ObjectPictureSource('player').path + local row={anim='PROBE'};s.moveAnimRow=row;s.queue={row} + require('src.battle.Gen2PicturePresentation').transform(s,s.player,s.enemy) + T.eq(s:gen2ObjectPictureSource('player').path,original, + 'queued Transform reads the pinned old picture, not its future resolved sprite') + p:start('PROBE',true);p:update() + local entry=p.runtime.loaded[2] + T.check(p.view.battlerRows[entry]~=nil,'pending picture snapshot still uses actual command') + local originalProvider=s.gen2ObjectPictureSource + sources['rgb-live']=F.image(96,48) + sources['rgb-live']:mapPixel(function(x)return x<48 and 0 or 1,.4,.2,.75 end) + function s:gen2ObjectPictureSource(side) + if side=='player'then return {path='rgb-live',trueColor=true,crop={index=2,width=48}}end + return originalProvider(self,side) + end + p:start('PROBE',true);p:update();draws={};p:draw() + T.eq(#draws,26) + for i=15,26 do + T.same(draws[i].colors,{},'actual custom RGB OAM clears preceding indexed shader') + T.same({draws[i].image:getPixel(0,0)},{1,.4,.2,.75},'actual OBJ copy retains selected RGB frame') + end + s.gen2ObjectPictureSource=originalProvider + -- A real raisesub command precedes the copy, and therefore the doll is + -- the current BG source. A later dropsub cannot alter these copied cells. + local code=p.anims.scripts[1].code + table.insert(code,1,{op='raisesub'}) + p.anims.pictureArt={version=1,substitute={player='qa-doll-back',enemy='qa-doll-front'}} + sources['qa-doll-back']=picture(48,48,2);sources['qa-doll-front']=picture(56,56,1) + p:start('PROBE',true);p:update() + T.eq(s:gen2ObjectPictureSource('player').path,'qa-doll-back','source follows real VM replacement state') + local copied=p.view.battlerRows[p.runtime.loaded[2]].image + local expected={sources['qa-doll-back']:getPixel(0,0)} + if expected[1]>.83 then expected[4]=0 end + T.same({copied:getPixel(0,0)},expected,'real native copy uses replacement pixels') + p.runtime.picOverride.player=false + T.eq(s:gen2ObjectPictureSource('player').path,original,'drop reveals native pinned Pokemon source') + T.same({copied:getPixel(0,0)},expected,'later visible picture change cannot mutate earlier OBJ copy') + p:release() +end Assets.imageData,love.image,g.newImage,g.draw,g.newShader=oldData,oldImage,oldNew,oldDraw,oldShader Assets.invalidate();V.set(oldVersion) T.finish('native GSC battler-row copy pixels, actual OAM, palettes, snapshots and lifetime') From b5b4eac77a3702ccd3e6446f1838f7163efc96a2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:58:24 +0100 Subject: [PATCH 095/187] Exercise replacement copies through native screen admission --- tests/engine/gen2_battler_rows.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/engine/gen2_battler_rows.lua b/tests/engine/gen2_battler_rows.lua index 1c4c79f8c..acd866edf 100644 --- a/tests/engine/gen2_battler_rows.lua +++ b/tests/engine/gen2_battler_rows.lua @@ -170,7 +170,7 @@ do require('src.battle.Gen2PicturePresentation').transform(s,s.player,s.enemy) T.eq(s:gen2ObjectPictureSource('player').path,original, 'queued Transform reads the pinned old picture, not its future resolved sprite') - p:start('PROBE',true);p:update() + s:animForMove('PROBE','player');p:update() local entry=p.runtime.loaded[2] T.check(p.view.battlerRows[entry]~=nil,'pending picture snapshot still uses actual command') local originalProvider=s.gen2ObjectPictureSource @@ -180,7 +180,7 @@ do if side=='player'then return {path='rgb-live',trueColor=true,crop={index=2,width=48}}end return originalProvider(self,side) end - p:start('PROBE',true);p:update();draws={};p:draw() + s:animForMove('PROBE','player');p:update();draws={};p:draw() T.eq(#draws,26) for i=15,26 do T.same(draws[i].colors,{},'actual custom RGB OAM clears preceding indexed shader') @@ -193,7 +193,7 @@ do table.insert(code,1,{op='raisesub'}) p.anims.pictureArt={version=1,substitute={player='qa-doll-back',enemy='qa-doll-front'}} sources['qa-doll-back']=picture(48,48,2);sources['qa-doll-front']=picture(56,56,1) - p:start('PROBE',true);p:update() + s:animForMove('PROBE','player');p:update() T.eq(s:gen2ObjectPictureSource('player').path,'qa-doll-back','source follows real VM replacement state') local copied=p.view.battlerRows[p.runtime.loaded[2]].image local expected={sources['qa-doll-back']:getPixel(0,0)} From b488a84ae350ded136330e8ddf0fe41992839598 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:02:25 +0100 Subject: [PATCH 096/187] Check outer palette uniforms around the actual native OAM renderer --- tests/engine/gen2_battler_rows.lua | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/engine/gen2_battler_rows.lua b/tests/engine/gen2_battler_rows.lua index acd866edf..8b445c18e 100644 --- a/tests/engine/gen2_battler_rows.lua +++ b/tests/engine/gen2_battler_rows.lua @@ -99,10 +99,16 @@ for _,edition in ipairs{'gold','silver','crystal'}do end local before,frame,random=decodes,p.frames,s.rng local images=uploads - draws={};local outside={};g.setShader(outside);P.setBgp(0) + draws={} + local outerColors={{251,239,227},{193,151,109},{83,61,47},{17,11,5}} + P.useRaw(outerColors);local outside=g.getShader();P.setBgp(0) p:draw();local once=draws T.eq(#once,13*rows,'both real picture strips reach the native OAM renderer') T.eq(g.getShader(),outside,'caller shader restored') + for i=1,4 do + T.same(outside.values['pal'..(i-1)],{outerColors[i][1]/255,outerColors[i][2]/255,outerColors[i][3]/255}, + 'caller palette uniforms survive reuse of the same indexed shader') + end local oam=runner:oam() for index,draw in ipairs(once)do local obj=oam[index] @@ -137,6 +143,10 @@ for _,edition in ipairs{'gold','silver','crystal'}do local draw=g.draw;g.draw=function()g.setShader({});error('row draw failure')end T.raises(function()p:drawSprites(retained)end,'row draw failure') T.eq(g.getShader(),outside,'failed drawing also restores caller graphics state') + for i=1,4 do + T.same(outside.values['pal'..(i-1)],{outerColors[i][1]/255,outerColors[i][2]/255,outerColors[i][3]/255}, + 'failed drawing restores caller palette uniforms as well as shader identity') + end g.draw=draw p:release();T.eq(snapshot.image.released,true,'view retirement releases retained snapshot images') P.setBgp(nil);g.setShader() From 74be6ecd25ed77eefda9fb94fabd0d3e74a7ed24 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:03:31 +0100 Subject: [PATCH 097/187] Restore outer indexed palette uniforms after native OAM drawing --- docs/native-gen2-battler-rows.md | 5 +++++ src/battle/Gen2AnimView.lua | 2 +- src/render/Gen2PaletteDraw.lua | 4 ++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/native-gen2-battler-rows.md b/docs/native-gen2-battler-rows.md index 38ed54048..2a636ace5 100644 --- a/docs/native-gen2-battler-rows.md +++ b/docs/native-gen2-battler-rows.md @@ -26,6 +26,11 @@ recolouring. Crystal crop descriptors select a single frame. Replacement art and queued Transform use the existing picture presentation authority; reading the source never reveals a queued future picture. +The OAM pass also restores the caller's indexed palette uniforms, including +when drawing throws. Graphics push/pop alone restores the shader reference +but cannot undo uploads to the same shader object. The stronger actual-draw +fixture reproduces 96 uniform mismatches before this palette-scope fix. + Copied images belong to the native view, keyed weakly by the actual loaded descriptor. Retaining an old runner retains its copied picture; a restarted animation cannot substitute its current image into old OAM. View retirement diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua index 25dfd3dde..7e235a8dd 100644 --- a/src/battle/Gen2AnimView.lua +++ b/src/battle/Gen2AnimView.lua @@ -70,7 +70,7 @@ function View:drawObjects(runner, battle) local g = love and love.graphics if not g then return end g.push('all') - local ok, err = pcall(function() + local ok, err = pcall(Palette.preserving,function() g.setColor(1,1,1,1) local active,activeByte,activeRaw for _, obj in ipairs(runner:oam()) do diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua index 481165c12..f2727e194 100644 --- a/src/render/Gen2PaletteDraw.lua +++ b/src/render/Gen2PaletteDraw.lua @@ -277,4 +277,8 @@ end function M.with(colors, body) return scoped(colors, body, M.use) end function M.withRaw(colors, body) return scoped(colors, body, M.useRaw) end function M.keyedWith(colors, body) return scoped(colors, body, M.useKeyed) end +-- Graphics push/pop retains a shader object, not its mutable uniforms. An +-- OAM pass selects several palettes itself, so preserve the outer indexed +-- palette without imposing an extra palette on the first object. +function M.preserving(body) return scoped(nil, body, function()return false end) end return M From 9ef86ed9dc661f5bf754a5b2122759fdb00e67bd Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:11:45 +0100 Subject: [PATCH 098/187] Add native GSC battler-row and outer-palette GPU pixel cases --- tests/gpu/gen2_battler_rows.lua | 183 ++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/gpu/gen2_battler_rows.lua diff --git a/tests/gpu/gen2_battler_rows.lua b/tests/gpu/gen2_battler_rows.lua new file mode 100644 index 000000000..978d64407 --- /dev/null +++ b/tests/gpu/gen2_battler_rows.lua @@ -0,0 +1,183 @@ +-- Generated ROM-free art, real native BattleState -> VM -> OBJ GPU pixels. +local M={} +function M.run() + local Version=require('src.core.GameVersion') + local Palette=require('src.render.PaletteFX') + local Registers=require('src.render.Gen2PaletteDraw') + local Battle=require('src.battle.BattleState') + local Assets=require('src.render.Assets') + local Data=require('src.core.Data') + local g=love.graphics + local previous={version=Version.get(),mode=Palette.mode,ramp=Palette.customRamp()} + local checks,cases=0,0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local bg={32/255,64/255,128/255,1} + local A={{255,255,255},{211,41,67},{29,173,101},{3,7,13}} + local B={{255,255,255},{47,91,223},{193,131,53},{11,17,31}} + local outer={{249,237,225},{191,149,107},{81,59,43},{19,13,7}} + local custom={{247,233,219},{181,151,121},{103,73,43},{23,13,7}} + local canvas=g.newCanvas(160,144,{format='rgba8',dpiscale=1});M.image=canvas + local paths={} + local function sample(kind,back,n,x,y) + if kind=='rgb'then return {x%2==0 and 1 or .2,.4,y%2==0 and .7 or .1,.75}end + if kind=='substitute'then + local top=back and 32 or 40 + if x<16 or x>=32 or y=top+16 then return {1,1,1,1}end + elseif kind=='minimize'then + local left,top=back and 24 or 24,back and 32 or 40 + if x=left+8 or y=top+8 then return {1,1,1,1}end + end + local shade=(math.floor(x/8)*3+math.floor(y/8)*2+x+2*y+(back and 1 or 0))%4 + local value=(3-shade)/3 + return {value,value,value,1},shade + end + for _,kind in ipairs{'normal','rgb','substitute','minimize'}do + paths[kind]={} + for _,back in ipairs{false,true}do + for _,frontSize in ipairs{40,48,56}do + local n=back and 48 or kind=='normal'and frontSize or 56 + local path='qa-row-'..kind..'-'..(back and 'back'or tostring(frontSize))..'.png' + local pixels=love.image.newImageData(n,n,'rgba8') + for y=0,n-1 do for x=0,n-1 do pixels:setPixel(x,y,unpack(sample(kind,back,n,x,y)))end end + pixels:encode('png',path);pixels:release() + paths[kind][back and 'back'or frontSize]=path + end + end + end + local function make(edition,rows,attacker,kind,frontSize) + Version.set(edition);Assets.invalidate();Battle.invalidate() + local data=require('tests.modkit.fixtures').fresh();data.constants.gen=2 + for name,def in pairs(data.pokemon)do + def.palette=name=='FIXMON_A'and 'ROW_BACK'or 'ROW_FRONT' + def.spriteFront=paths[kind][frontSize];def.spriteBack=paths[kind].back + def.trueColor=kind=='rgb'or nil + end + data.palettes={palettes={ROW_BACK=B,ROW_FRONT=A,GRAYMON=A,MEWMON=A}, + pokemon={FIXMON_A='ROW_BACK',FIXMON_B='ROW_FRONT',FIXMON_C='ROW_FRONT'}} + Data.publishGen2Palettes(data) + data.constants.battleAnimGfxOrder={'BATTLE_ANIM_GFX_PLAYERHEAD','BATTLE_ANIM_GFX_ENEMYFEET'} + local code={} + if kind=='substitute'or kind=='minimize'then + code[#code+1]={op=kind=='substitute'and 'raisesub'or 'minimizeopp'} + end + code[#code+1]={op=rows==2 and 'battlergfx_1row'or 'battlergfx_2row'} + code[#code+1]={op='obj',args={0,76,40,0}} + code[#code+1]={op='obj',args={1,40,100,0}} + code[#code+1]={op='delay',frames=100};code[#code+1]={op='ret'} + data.battle_anims={gen2=true,moveAnims={PROBE=1},scripts={[1]={entry=1,code=code}}, + functions={[0]='MoveFromUserToTarget'},objects={},framesets={},oam={}} + if kind=='substitute'or kind=='minimize'then + data.battle_anims.pictureArt={version=1,[kind]={ + enemy=paths[kind][frontSize],player=paths[kind].back}} + -- The opposite battler remains a regular Pokemon. Give it generated + -- native art too; the real command decides which picture is replaced. + for _,def in pairs(data.pokemon)do + def.spriteFront=paths.normal[frontSize];def.spriteBack=paths.normal.back + end + end + for id=0,1 do + data.battle_anims.objects[id]={oamFlags=1,fixY=255,fn=0,frameset=id,palette=id,gfx=id} + data.battle_anims.framesets[id]={frames={{oam=id,duration=1}},loop='restart'} + local sprites={} + for tile=0,(id==0 and 7 or 6)*rows-1 do + sprites[#sprites+1]={x=math.floor(tile/rows)*8,y=(tile%rows)*8, + tile=tile,attr=(tile%4)*32} + end + data.battle_anims.oam[id]={tile=0,sprites=sprites} + end + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local screen=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + screen.queue,screen.current,screen.waitFrames={},nil,nil + check(screen:animForMove('PROBE',attacker),'native screen did not admit row animation') + screen.animPlayer:update() + return screen + end + g.push('all') + local ok,why=xpcall(function() + for ei,edition in ipairs{'gold','silver','crystal'}do + local frontSize=32+ei*8 + for _,kind in ipairs{'normal','rgb','substitute','minimize'}do + if kind=='normal'or kind=='rgb'or edition=='crystal'then + for _,mode in ipairs(kind=='rgb'and {'gbc'}or {'gbc','dmg','custom'})do + Palette.setDisplay(mode,mode=='custom'and custom or nil) + for _,rows in ipairs{1,2}do for _,attacker in ipairs{'player','enemy'}do + local screen=make(edition,rows,attacker,kind,frontSize) + local player=screen.animPlayer;local runner=player.runtime + local expected={} + for _,obj in ipairs(runner:oam())do + local entry + for i=#runner.loaded,1,-1 do + local item=runner.loaded[i] + if obj.tile>=item.tile and obj.tile=0 and yy>=0 and xx=0 and dx<160 and dy>=0 and dy<144 and raw then + local alpha=currentKind=='rgb'and raw[4]or (shade and shade>0 and 1 or 0) + if alpha>0 then + local rgb=currentKind=='rgb'and raw or palette[shade+1] + local value={} + for channel=1,3 do + local component=currentKind=='rgb'and rgb[channel]or rgb[channel]/255 + value[channel]=component*alpha+bg[channel]*(1-alpha) + end + value[4]=1;expected[dy*160+dx]=value + end + end + end end + end + g.setCanvas(canvas);g.origin();g.setScissor();g.setBlendMode('alpha','alphamultiply') + g.setShader();g.clear(unpack(bg));g.setColor(.2,.3,.4,.5) + Registers.useRaw(outer);local external=g.getShader() + Registers.setBgp(0) -- a BG fade cannot fade copied OBJ pixels + local clock=player.frames + player:draw() + check(player.frames==clock,'GPU drawing advanced the VM') + check(g.getShader()==external,'GPU OAM pass lost outer shader identity') + check(math.abs(select(4,g.getColor())-.5)<1/255,'GPU OAM pass lost caller tint') + -- Draw a four-pixel outer-palette probe after the actual OAM + -- pass. This proves uniforms, not merely shader identity. + local probe=love.image.newImageData(4,1,'rgba8') + for x=0,3 do local gray=1-x/3;probe:setPixel(x,0,gray,gray,gray,1)end + local image=g.newImage(probe);image:setFilter('nearest','nearest');probe:release() + g.setColor(1,1,1,1);g.draw(image,0,140);image:release() + for x=0,3 do expected[140*160+x]={outer[x+1][1]/255,outer[x+1][2]/255,outer[x+1][3]/255,1}end + g.setCanvas();g.setShader();Registers.setBgp(nil) + local pixels=g.readbackTexture(canvas) + for y=0,143 do for x=0,159 do + local got={pixels:getPixel(x,y)};local wanted=expected[y*160+x]or bg + for channel=1,4 do check(math.abs(got[channel]-wanted[channel])<2/255, + edition..' '..kind..' '..mode..' rows'..rows..' '..attacker..' pixel '..x..','..y..' channel'..channel)end + end end + pixels:release();player:release();cases=cases+1 + end end + end + end + end + end + end,debug.traceback) + g.setCanvas();g.pop();Registers.setBgp(nil) + Version.set(previous.version);Palette.setDisplay(previous.mode,previous.ramp) + if not ok then error(why,0)end + return {passed=true,checks=checks,cases=cases, + scope='ROM-free real native BattleState/VM/OBJ GPU pixel equality: GSC, native 5/6/7-tile fronts, both attackers, one/two copied rows, flips, GBC/DMG/custom, RGB alpha, substitute/minimize, outer palette probe and stable draw clock. Generated test art; not unchanged-mod, OS-window, performance or release acceptance.'},canvas +end +return M From 0626de1cde7b03991d4811f0ff907ca131bbebc3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:14:40 +0100 Subject: [PATCH 099/187] Pass only RGBA components when generating GPU fixture pixels --- tests/gpu/gen2_battler_rows.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/gpu/gen2_battler_rows.lua b/tests/gpu/gen2_battler_rows.lua index 978d64407..601e7d9ca 100644 --- a/tests/gpu/gen2_battler_rows.lua +++ b/tests/gpu/gen2_battler_rows.lua @@ -38,7 +38,10 @@ function M.run() local n=back and 48 or kind=='normal'and frontSize or 56 local path='qa-row-'..kind..'-'..(back and 'back'or tostring(frontSize))..'.png' local pixels=love.image.newImageData(n,n,'rgba8') - for y=0,n-1 do for x=0,n-1 do pixels:setPixel(x,y,unpack(sample(kind,back,n,x,y)))end end + for y=0,n-1 do for x=0,n-1 do + local rgba=sample(kind,back,n,x,y) + pixels:setPixel(x,y,unpack(rgba)) + end end pixels:encode('png',path);pixels:release() paths[kind][back and 'back'or frontSize]=path end From 95b104a149d8fab836795ea57e5b90f8d411b614 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:30:34 +0100 Subject: [PATCH 100/187] Use the complete native GSC ROM animation pool in the actual VM --- .../workflows/gen2-picture-presentation.yml | 1 + docs/native-gen2-animation-catalog.md | 39 +++++++++ src/battle/Gen2AnimCatalog.lua | 81 ++++++++++++++++++ src/battle/Gen2AnimData.lua | 36 +++++--- src/battle/Gen2AnimPlayer.lua | 14 +++- src/import/RomExtractorGen2.lua | 9 +- tests/engine/gen2_animation_catalog.lua | 84 +++++++++++++++++++ .../engine/gen2_animation_import_commands.lua | 7 +- tests/gpu/gen2_battler_rows.lua | 1 + 9 files changed, 252 insertions(+), 20 deletions(-) create mode 100644 docs/native-gen2-animation-catalog.md create mode 100644 src/battle/Gen2AnimCatalog.lua create mode 100644 tests/engine/gen2_animation_catalog.lua diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml index 9a68670e4..97b8f6bf7 100644 --- a/.github/workflows/gen2-picture-presentation.yml +++ b/.github/workflows/gen2-picture-presentation.yml @@ -22,6 +22,7 @@ jobs: luajit tests/engine/gen2_picture_art_import.lua luajit tests/engine/gen2_picture_art_draw.lua luajit tests/engine/gen2_battler_rows.lua + luajit tests/engine/gen2_animation_catalog.lua - name: Existing command, object and background owners run: | luajit tests/engine/gen2_object_runtime.lua diff --git a/docs/native-gen2-animation-catalog.md b/docs/native-gen2-animation-catalog.md new file mode 100644 index 000000000..9030766df --- /dev/null +++ b/docs/native-gen2-animation-catalog.md @@ -0,0 +1,39 @@ +# Complete native GSC animation data + +Contributions by Roxas2712. Existing interpreter and identifier provenance +and applicable licences remain in `compat/gen1-gsc/` and `NOTICE.md`. + +The importer previously stored a private flat program per animation index. +The player converted only its selected program into VM rows. Consequently +`AnimRunner.scriptForMove(data.battle_anims, move)` returned nothing and a +runner's data omitted every unselected move and shared call-only program. + +The ROM decoder now retains actual instruction addresses. The importer +publishes the complete native interpreter schema: `moves`, named `ids`, +`scripts`, `scriptOrder`, objects, framesets, OAM sets, GFX and palettes. +Programs use their genuine hexadecimal ROM address, including shared +subroutines and the dummy table entry. Each program appears once; different +bytes claiming the same address are rejected. Branches contain one resolved +target key, without leftover pointer bytes. Registered non-ROM flat test +programs use an explicit `program::` namespace rather than +pretending to have cartridge addresses. + +Native `Gen2AnimPlayer` and its actual runtime/view use that complete imported +table directly. Changing moves changes the entry point and VM state, not the +data owner. Standalone source calls and source edits observe the same pool. +The previous flat input remains accepted for existing program registrations; +there is still one real interpreter, object pool and battle clock. + +Gen2 cache revision 3 requests reimport of earlier generated animation data. +Saves and unrelated generation cache markers retain their existing contracts. +The import edition supplies the BG-function order independently of whatever +cartridge happens to be selected by a test or loader. + +`tests/engine/gen2_animation_catalog.lua` reads generated ROM bytes through +the actual extractor and player, checks shared and call-only programs, +unselected moves, numeric/dummy entries, source edits and native wait/return +time. The GPU row fixture now also enters through the complete native data +schema. Actual ROM differential and GPU acceptance results belong to their +recorded source revisions; adding this path alone is not full unchanged-mod, +Windows or packaged-release acceptance. No mod or ROM program is edited or +committed. diff --git a/src/battle/Gen2AnimCatalog.lua b/src/battle/Gen2AnimCatalog.lua new file mode 100644 index 000000000..84c9298e0 --- /dev/null +++ b/src/battle/Gen2AnimCatalog.lua @@ -0,0 +1,81 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native GSC ROM programs in the actual interpreter's complete data schema. +-- Command/identifier provenance: compat/gen1-gsc; no ROM scripts authored here. +local Data=require('src.battle.Gen2AnimData') +local M={} +local function same(a,b) + if type(a)~=type(b)then return false end + if type(a)~='table'then return a==b end + for k,v in pairs(a)do if not same(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +function M.compile(imported,constants,version) + if Data.isNative(imported)then return imported end + local catalog=Data.tables(imported,constants,version) + catalog.gen2,catalog.generation,catalog.format=true,2,'gsc-native-v1' + catalog.commandFormat=imported.commandFormat + catalog.source='ROM:BattleAnimations + data/battle_anims/*' + catalog.bank,catalog.gameVersion=imported.bank,version + catalog.scripts,catalog.scriptOrder,catalog.moves,catalog.ids,catalog.indices={},{},{},{},{} + catalog.misc,catalog.moveAnims,catalog.move,catalog.effects={},{},{},{} + local ids={};for id in pairs(imported.scripts or {})do ids[#ids+1]=id end + table.sort(ids,function(a,b)if type(a)==type(b)then return a complete native pool -> actual shared VM. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local E=require('src.import.RomExtractorGen2') +local Data=require('src.battle.Gen2AnimData') +local Catalog=require('src.battle.Gen2AnimCatalog') +local Runtime=require('src.battle.gen2.AnimRunner') +local Player=require('src.battle.Gen2AnimPlayer') +local V=require('src.core.GameVersion') +local previous=V.get() +for _,edition in ipairs{'gold','silver','crystal'}do + V.set(edition) + local constants={moveOrder={'PROBE','OTHER','ALIAS'}} + local e=E.new(nil,edition,{symbols={BattleAnimations={1,0x4000}}}) + e.constants=function()return constants end + local pointers={[0x4000]=0x4008,[0x4002]=0x5000,[0x4004]=0x5010,[0x4006]=0x5000} + local bytes={ + [0x4008]=0xff, + [0x5000]=0xfe,[0x5001]=0x00,[0x5002]=0x51,[0x5003]=3,[0x5004]=0xff, + [0x5010]=1,[0x5011]=0xff, + [0x5100]=2,[0x5101]=0xff, + } + e.rom={word=function(_,bank,pc)T.eq(bank,1);return assert(pointers[pc])end, + byte=function(_,bank,pc)T.eq(bank,1);return assert(bytes[pc])end} + local c=e:gen2BattleAnims() + T.eq(Data.isNative(c),true,'actual ROM importer publishes native interpreter schema') + T.eq(c.bank,1);T.eq(c.gameVersion,edition) + T.eq(c.moves.PROBE,'5000');T.eq(c.moves.OTHER,'5010');T.eq(c.moves.ALIAS,'5000') + T.eq(c.indices[0],'4008','cartridge dummy entry is part of the complete pool') + T.same(c.scriptOrder,{'4008','5000','5100','5010'},'pool includes call-only blocks and unselected moves once') + T.same(c.scripts['5000'],{{'call','5100'},{'wait',3},{'ret'}}) + T.same(c.scripts['5100'],{{'wait',2},{'ret'}}) + T.same(c.scripts['5010'],{{'wait',1},{'ret'}}) + T.eq(c.scripts['5000'],c.scripts[c.moves.ALIAS],'same ROM address has one real command owner') + T.eq(Runtime.scriptForMove(c,'PROBE'),'5000','standalone native API uses the actual imported dataset') + T.eq(Runtime.scriptForMove(c,'ABSENT'),nil,'absent move gets no invented animation') + local view,orders=Data.tables(c,constants) + T.eq(view,c,'native data does not get copied into a selected-program facade') + local bodySlam=false + for _,name in ipairs(orders.battleBgEffectOrder)do if name=='BATTLE_BG_EFFECT_BODY_SLAM'then bodySlam=true end end + T.eq(bodySlam,edition=='crystal','import edition owns numeric BG-function order') + local p=Player.new({battle_anims=c,constants=constants}) + T.eq(p.runtime.data,c,'idle native player already exposes all imported programs') + T.check(p:start('PROBE',true));local first=p.runtime + T.eq(first.data,c);T.eq(p.view.data,c,'the real OAM view reads the same complete native pool') + T.eq(first.data.scripts['5010'],c.scripts['5010'],'unselected program stays available during another move') + local steps=0 + repeat p:update();steps=steps+1;T.check(steps<20)until p:isDone() + T.eq(p.frames,8,'real call, full waits, parent resume and ret preserve cartridge time') + T.eq(p:scriptFor('ALIAS'),p:scriptFor('PROBE')) + T.check(p:start('OTHER',false));T.eq(p.runtime.data,c,'next move does not replace the shared data owner') + repeat p:update()until p:isDone() + T.eq(p.frames,3);T.eq(first.frames,8,'retained runner keeps its original native clock') + -- Native clients can edit an actual canonical program. The next start sees + -- that same owner; it must not silently rebuild it from stale legacy data. + c.scripts['5010'][1]={'wait',4} + T.check(p:start('OTHER',false)) + repeat p:update()until p:isDone() + T.eq(p.frames,6,'actual native source-program edit reaches the genuine VM') + p:release() + local missing=E.new(nil,edition,{symbols={}}):gen2BattleAnims() + T.eq(Data.isNative(missing),false,'missing ROM animation table remains an explicit unsupported result') +end +do + local c=Catalog.compile({gen2=true,scripts={[1]={entry=1,code={ + {op='ifparamequal',args={5,0,80},to=3},{op='ret'},{op='delay',frames=2},{op='ret'}}}}, + moveAnims={PROBE=1},objects={},framesets={},oam={},gfx={}},nil,'crystal') + T.eq(c.moves.PROBE,'program:1:1','non-ROM registration has an explicit program namespace') + T.same(c.scripts['program:1:1'],{{'if_param_equal',5,'program:1:3'},{'ret'}}, + 'canonical branch has one target key, not leftover cartridge pointer bytes') + T.same(c.scripts['program:1:3'],{{'wait',2},{'ret'}}) + local loop=Catalog.compile({scripts={[1]={entry=1,addresses={0x5000},code={{op='jump',to=1}}}}, + moveAnims={PROBE=1}},nil,'gold') + T.same(loop.scripts['5000'],{{'jump','5000'}},'self branch neither recurses forever nor duplicates the pool') + T.same(loop.scriptOrder,{'5000'}) + T.raises(function()Catalog.compile({scripts={ + [1]={entry=1,addresses={0x5000,0x5001},code={{op='delay',frames=1},{op='ret'}}}, + [2]={entry=1,addresses={0x5000,0x5001},code={{op='delay',frames=2},{op='ret'}}}}},nil,'gold')end, + 'conflicting GSC program','different bytes cannot silently share a ROM program owner') + T.same(Data.row({op='call',args={0,80},to=1},'5000'),{'call','5000'}) + T.same(Data.row({op='loop',args={3,0,80},to=1},'5000'),{'loop',3,'5000'}) +end +V.set(previous) +T.finish('complete native GSC ROM animation pool, source API, actual VM and shared ownership') diff --git a/tests/engine/gen2_animation_import_commands.lua b/tests/engine/gen2_animation_import_commands.lua index 633863139..f26c2fd06 100644 --- a/tests/engine/gen2_animation_import_commands.lua +++ b/tests/engine/gen2_animation_import_commands.lua @@ -44,10 +44,11 @@ for _,edition in ipairs({'gold','silver','crystal'})do end for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do local marker=markerFor(edition) - T.check(marker:find('gen2%-anim%-object%-v2:')~=nil,'generated animation cache has a new contract') + T.check(marker:find('gen2%-anim%-object%-v3:')~=nil,'generated animation cache has a new contract') Version.set(edition) - for _,old in ipairs({marker:gsub('gen2%-anim%-object%-v2:',''), - (marker:gsub('gen2%-anim%-object%-v2:','gen2-anim-object-v1:'))})do + for _,old in ipairs({marker:gsub('gen2%-anim%-object%-v3:',''), + (marker:gsub('gen2%-anim%-object%-v3:','gen2-anim-object-v1:')), + (marker:gsub('gen2%-anim%-object%-v3:','gen2-anim-object-v2:'))})do love.filesystem.write(Version.cachePrefix(edition)..'rom-cache.complete',old) local report=Importer.readyReport(edition) T.eq(report.ok,false,'old commands or missing-picture cache must be rebuilt') diff --git a/tests/gpu/gen2_battler_rows.lua b/tests/gpu/gen2_battler_rows.lua index 601e7d9ca..1e2be2ef2 100644 --- a/tests/gpu/gen2_battler_rows.lua +++ b/tests/gpu/gen2_battler_rows.lua @@ -88,6 +88,7 @@ function M.run() end data.battle_anims.oam[id]={tile=0,sprites=sprites} end + data.battle_anims=require('src.battle.Gen2AnimCatalog').compile(data.battle_anims,data.constants,edition) require('src.render.Font').load(data) local save=require('src.core.SaveData').newGame() save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} From b676bbc3650cfca7db81697582714260102642db Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:33:12 +0100 Subject: [PATCH 101/187] Admit canonical source programs and preserve their generated-cache ownership --- src/battle/Gen2MovePresentation.lua | 2 +- tests/engine/gen2_animation_catalog.lua | 16 ++++++++++++++++ tests/gpu/gen2_battler_rows.lua | 3 ++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/battle/Gen2MovePresentation.lua b/src/battle/Gen2MovePresentation.lua index bd3ef9087..a09020c17 100644 --- a/src/battle/Gen2MovePresentation.lua +++ b/src/battle/Gen2MovePresentation.lua @@ -38,7 +38,7 @@ function M.install(Screen) function Screen:animForMove(moveId,side,param,effectiveness,afterAnim) local player=self.animPlayer if not (Version.isGen2() and self:animationsOn() and player - and player.anims.moveAnims and player.anims.moveAnims[moveId]) then return false end + and player.anims.gen2 and player:scriptFor(moveId)) then return false end local started=M.start(self,moveId,side~='enemy',{param=param or 0}) if started then local name=self:afterAnimFor(side,afterAnim) diff --git a/tests/engine/gen2_animation_catalog.lua b/tests/engine/gen2_animation_catalog.lua index c25b600fb..e077723c6 100644 --- a/tests/engine/gen2_animation_catalog.lua +++ b/tests/engine/gen2_animation_catalog.lua @@ -58,6 +58,22 @@ for _,edition in ipairs{'gold','silver','crystal'}do repeat p:update()until p:isDone() T.eq(p.frames,6,'actual native source-program edit reaches the genuine VM') p:release() + local source=require('src.import.LuaWriter').encodeSplit(c) + local restored=assert(loadstring(source))() + T.same(restored,c,'actual generated-cache writer preserves the complete native schema') + T.eq(Data.tables(restored,constants),restored,'restored data remains the native owner') + local fixtures=T.fixtures.fresh();fixtures.constants.gen=2;fixtures.battle_anims=restored + require('src.render.Font').load(fixtures) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(fixtures,'FIXMON_A',20)} + local screen=require('src.battle.BattleState').newWild({data=fixtures,save=save, + stack={top=function()end}},'FIXMON_C',15) + restored.moves.ADDED='5010' + T.eq(restored.moveAnims.ADDED,nil,'new native registration does not need a stale legacy index map') + T.check(screen:animForMove('ADDED','player'),'actual native screen admits the canonical source registration') + T.eq(screen.animPlayer.runtime.data,restored,'real screen, public data and VM share the restored owner') + T.eq(screen.anims,restored,'real screen exposes the complete native dataset') + screen.animPlayer:release() local missing=E.new(nil,edition,{symbols={}}):gen2BattleAnims() T.eq(Data.isNative(missing),false,'missing ROM animation table remains an explicit unsupported result') end diff --git a/tests/gpu/gen2_battler_rows.lua b/tests/gpu/gen2_battler_rows.lua index 1e2be2ef2..429e6f705 100644 --- a/tests/gpu/gen2_battler_rows.lua +++ b/tests/gpu/gen2_battler_rows.lua @@ -88,7 +88,8 @@ function M.run() end data.battle_anims.oam[id]={tile=0,sprites=sprites} end - data.battle_anims=require('src.battle.Gen2AnimCatalog').compile(data.battle_anims,data.constants,edition) + local catalog=require('src.battle.Gen2AnimCatalog').compile(data.battle_anims,data.constants,edition) + data.battle_anims=assert(loadstring(require('src.import.LuaWriter').encodeSplit(catalog)))() require('src.render.Font').load(data) local save=require('src.core.SaveData').newGame() save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} From 529a4b5a448161953243cfd6ed2ffd78ac1b8a96 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:42:31 +0100 Subject: [PATCH 102/187] Accept the genuine source-native GSC dataset without private fork flags --- docs/native-gen2-animation-catalog.md | 2 ++ src/battle/BattleState.lua | 10 +++++----- src/battle/Gen2AnimData.lua | 6 +++++- src/battle/Gen2MovePresentation.lua | 5 +++-- src/battle/Gen2PicturePresentation.lua | 3 ++- tests/engine/gen2_animation_catalog.lua | 10 ++++++++++ tests/gpu/gen2_battler_rows.lua | 1 + 7 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/native-gen2-animation-catalog.md b/docs/native-gen2-animation-catalog.md index 9030766df..aa00c41ef 100644 --- a/docs/native-gen2-animation-catalog.md +++ b/docs/native-gen2-animation-catalog.md @@ -21,6 +21,8 @@ pretending to have cartridge addresses. Native `Gen2AnimPlayer` and its actual runtime/view use that complete imported table directly. Changing moves changes the entry point and VM state, not the data owner. Standalone source calls and source edits observe the same pool. +The original source-native `generation=2` schema is accepted directly, +without requiring the fork's private format or legacy index flags. The previous flat input remains accepted for existing program registrations; there is still one real interpreter, object pool and battle clock. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 2ea25e83c..702c0a97a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1254,7 +1254,7 @@ local function newBattle(game) -- rather than Gen 1's subanimation/frame-block tables), so they get -- their own player. if game.data.battle_anims then - if game.data.battle_anims.gen2 then + if require('src.battle.Gen2AnimData').isGen2(game.data.battle_anims) then local ok, Gen2AnimPlayer = pcall(require, "src.battle.Gen2AnimPlayer") if ok then self.animPlayer = Gen2AnimPlayer.new(game.data) @@ -2342,7 +2342,7 @@ end -- `isPlayer` follows the poof's convention (Gen2AnimPlayer flips it): false -- for the player's own mon, true for the foe's. function BattleState:shinyAnim(battler, isPlayer) - if not (self.data and self.data.battle_anims and self.data.battle_anims.gen2) then + if not (self.data and require('src.battle.Gen2AnimData').isGen2(self.data.battle_anims)) then return false end local mon = battler and battler.mon @@ -2355,7 +2355,7 @@ end -- before the queue starts running, where animNext's insert point does not -- apply. function BattleState:shinyAnimAppend(battler, isPlayer) - if not (self.data and self.data.battle_anims and self.data.battle_anims.gen2) then + if not (self.data and require('src.battle.Gen2AnimData').isGen2(self.data.battle_anims)) then return false end local mon = battler and battler.mon @@ -10388,7 +10388,7 @@ end function BattleState:gen3ShinyBurst(battler) if not battler then return false end if not self:gen3ThrowsSendOut() then return false end - if self.data and self.data.battle_anims and self.data.battle_anims.gen2 then + if self.data and require('src.battle.Gen2AnimData').isGen2(self.data.battle_anims) then return false -- Johto has its own SHINY_ANIM; this is the Gen 3 dataset end local mon = battler.mon @@ -10532,7 +10532,7 @@ function BattleState:ballChain(tossAnim, caught, shakes, ball) -- (anim_checkpokeball -> GetPokeBallWobble) and finally the click or the -- break-out, all from one script. Replaying Gen 1's POOF/HIDEPIC/SHAKE -- rows on top of it would only double up pieces of what it just did. - if self.data and self.data.battle_anims and self.data.battle_anims.gen2 then + if self.data and require('src.battle.Gen2AnimData').isGen2(self.data.battle_anims) then self:animNext(tossAnim, true, shakes, ball, caught) if caught then self:actNext(function() diff --git a/src/battle/Gen2AnimData.lua b/src/battle/Gen2AnimData.lua index 0fac83125..9ad540a77 100644 --- a/src/battle/Gen2AnimData.lua +++ b/src/battle/Gen2AnimData.lua @@ -5,7 +5,11 @@ local Ids=require('src.battle.Gen2AnimObjectIds') local M={} M.CACHE_REVISION=3 function M.isNative(anims) - return anims and anims.generation==2 and anims.format=='gsc-native-v1' + return anims and anims.generation==2 and type(anims.scripts)=='table' + and type(anims.moves)=='table'and type(anims.oamsets)=='table' +end +function M.isGen2(anims) + return anims and (anims.gen2==true or M.isNative(anims)) end local function order(constants,key) return constants and constants[key] or Ids[key] diff --git a/src/battle/Gen2MovePresentation.lua b/src/battle/Gen2MovePresentation.lua index a09020c17..3a66fcf76 100644 --- a/src/battle/Gen2MovePresentation.lua +++ b/src/battle/Gen2MovePresentation.lua @@ -3,11 +3,12 @@ -- battle decisions, damage, PP, queue consumption and time stay native. local Version=require('src.core.GameVersion') local View=require('src.battle.Gen2AnimView') +local Data=require('src.battle.Gen2AnimData') local M={} function M.start(screen,id,isPlayer,opts) local player=screen.animPlayer - if not (Version.isGen2() and player and player.anims.gen2 + if not (Version.isGen2() and player and Data.isGen2(player.anims) and player:scriptFor(id)) then return false end if player:start(id,isPlayer,opts)==false then return false end screen.animPlaying=true @@ -38,7 +39,7 @@ function M.install(Screen) function Screen:animForMove(moveId,side,param,effectiveness,afterAnim) local player=self.animPlayer if not (Version.isGen2() and self:animationsOn() and player - and player.anims.gen2 and player:scriptFor(moveId)) then return false end + and Data.isGen2(player.anims) and player:scriptFor(moveId)) then return false end local started=M.start(self,moveId,side~='enemy',{param=param or 0}) if started then local name=self:afterAnimFor(side,afterAnim) diff --git a/src/battle/Gen2PicturePresentation.lua b/src/battle/Gen2PicturePresentation.lua index 2f8c4e2d4..8d6a0e2b6 100644 --- a/src/battle/Gen2PicturePresentation.lua +++ b/src/battle/Gen2PicturePresentation.lua @@ -1,6 +1,7 @@ -- Picture identity follows the existing GSC animation VM, not the earlier -- native damage/effect calculation. No combat value or saved mon is delayed. local Version=require('src.core.GameVersion') +local Data=require('src.battle.Gen2AnimData') local M={} local function side(b) return b.isPlayer and 'player' or 'enemy' end @@ -25,7 +26,7 @@ end local function mark(screen,b,kind) local row=screen.moveAnimRow local player=screen.animPlayer - if not (Version.isGen2() and row and screen:animationsOn() and player and player.anims.gen2 + if not (Version.isGen2() and row and screen:animationsOn() and player and Data.isGen2(player.anims) and player:scriptFor(row.anim))then return end local queued=false for _,item in ipairs(screen.queue or {})do if item==row then queued=true;break end end diff --git a/tests/engine/gen2_animation_catalog.lua b/tests/engine/gen2_animation_catalog.lua index e077723c6..187d96342 100644 --- a/tests/engine/gen2_animation_catalog.lua +++ b/tests/engine/gen2_animation_catalog.lua @@ -74,6 +74,16 @@ for _,edition in ipairs{'gold','silver','crystal'}do T.eq(screen.animPlayer.runtime.data,restored,'real screen, public data and VM share the restored owner') T.eq(screen.anims,restored,'real screen exposes the complete native dataset') screen.animPlayer:release() + -- Gen1Recomp's native GSC schema has generation=2 and these real tables, + -- without the old fork's private gen2/format/moveAnims/index markers. + restored.gen2,restored.format,restored.moveAnims,restored.indices,restored.misc=nil,nil,nil,nil,nil + T.eq(Data.isNative(restored),true,'source-native data needs no fork-only format flag') + local native=require('src.battle.BattleState').newWild({data=fixtures,save=save, + stack={top=function()end}},'FIXMON_C',15) + T.check(native:animForMove('ADDED','player'),'real screen accepts the original source-native schema') + T.eq(native.animPlayer.runtime.data,restored) + T.eq(Runtime.scriptForMove(restored,'PROBE'),'5000') + native.animPlayer:release() local missing=E.new(nil,edition,{symbols={}}):gen2BattleAnims() T.eq(Data.isNative(missing),false,'missing ROM animation table remains an explicit unsupported result') end diff --git a/tests/gpu/gen2_battler_rows.lua b/tests/gpu/gen2_battler_rows.lua index 429e6f705..f9bf43000 100644 --- a/tests/gpu/gen2_battler_rows.lua +++ b/tests/gpu/gen2_battler_rows.lua @@ -89,6 +89,7 @@ function M.run() data.battle_anims.oam[id]={tile=0,sprites=sprites} end local catalog=require('src.battle.Gen2AnimCatalog').compile(data.battle_anims,data.constants,edition) + catalog.gen2,catalog.format,catalog.moveAnims,catalog.indices,catalog.misc=nil,nil,nil,nil,nil data.battle_anims=assert(loadstring(require('src.import.LuaWriter').encodeSplit(catalog)))() require('src.render.Font').load(data) local save=require('src.core.SaveData').newGame() From fa50c0eb85ee5abc17259abc38bb21523ebd9ffb Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:00:59 +0100 Subject: [PATCH 103/187] Keep registered animation provenance distinct from actual ROM imports --- src/battle/Gen2AnimCatalog.lua | 3 ++- tests/engine/gen2_animation_catalog.lua | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/battle/Gen2AnimCatalog.lua b/src/battle/Gen2AnimCatalog.lua index 84c9298e0..a0579ae50 100644 --- a/src/battle/Gen2AnimCatalog.lua +++ b/src/battle/Gen2AnimCatalog.lua @@ -15,7 +15,8 @@ function M.compile(imported,constants,version) local catalog=Data.tables(imported,constants,version) catalog.gen2,catalog.generation,catalog.format=true,2,'gsc-native-v1' catalog.commandFormat=imported.commandFormat - catalog.source='ROM:BattleAnimations + data/battle_anims/*' + catalog.source=imported.source or (imported.bank + and 'ROM:BattleAnimations + data/battle_anims/*'or 'Registered GSC animation programs') catalog.bank,catalog.gameVersion=imported.bank,version catalog.scripts,catalog.scriptOrder,catalog.moves,catalog.ids,catalog.indices={},{},{},{},{} catalog.misc,catalog.moveAnims,catalog.move,catalog.effects={},{},{},{} diff --git a/tests/engine/gen2_animation_catalog.lua b/tests/engine/gen2_animation_catalog.lua index 187d96342..d2646ad4f 100644 --- a/tests/engine/gen2_animation_catalog.lua +++ b/tests/engine/gen2_animation_catalog.lua @@ -25,6 +25,7 @@ for _,edition in ipairs{'gold','silver','crystal'}do local c=e:gen2BattleAnims() T.eq(Data.isNative(c),true,'actual ROM importer publishes native interpreter schema') T.eq(c.bank,1);T.eq(c.gameVersion,edition) + T.eq(c.source,'ROM:BattleAnimations + data/battle_anims/*','ROM provenance remains explicit') T.eq(c.moves.PROBE,'5000');T.eq(c.moves.OTHER,'5010');T.eq(c.moves.ALIAS,'5000') T.eq(c.indices[0],'4008','cartridge dummy entry is part of the complete pool') T.same(c.scriptOrder,{'4008','5000','5100','5010'},'pool includes call-only blocks and unselected moves once') @@ -92,6 +93,7 @@ do {op='ifparamequal',args={5,0,80},to=3},{op='ret'},{op='delay',frames=2},{op='ret'}}}}, moveAnims={PROBE=1},objects={},framesets={},oam={},gfx={}},nil,'crystal') T.eq(c.moves.PROBE,'program:1:1','non-ROM registration has an explicit program namespace') + T.eq(c.source,'Registered GSC animation programs','registered programs do not claim cartridge provenance') T.same(c.scripts['program:1:1'],{{'if_param_equal',5,'program:1:3'},{'ret'}}, 'canonical branch has one target key, not leftover cartridge pointer bytes') T.same(c.scripts['program:1:3'],{{'wait',2},{'ret'}}) From 793035db88219579a732f6c13a61c93364515c03 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:29:46 +0100 Subject: [PATCH 104/187] Bind GSC Fly and Dig visibility to the actual native queue and animation VM --- .../workflows/gen2-picture-presentation.yml | 1 + src/battle/BattleState.lua | 23 +- src/battle/Gen2MovePresentation.lua | 6 +- src/battle/Gen2VanishPresentation.lua | 101 +++++++++ tests/engine/gen2_vanish_presentation.lua | 203 ++++++++++++++++++ 5 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 src/battle/Gen2VanishPresentation.lua create mode 100644 tests/engine/gen2_vanish_presentation.lua diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml index 97b8f6bf7..e935b6660 100644 --- a/.github/workflows/gen2-picture-presentation.yml +++ b/.github/workflows/gen2-picture-presentation.yml @@ -23,6 +23,7 @@ jobs: luajit tests/engine/gen2_picture_art_draw.lua luajit tests/engine/gen2_battler_rows.lua luajit tests/engine/gen2_animation_catalog.lua + luajit tests/engine/gen2_vanish_presentation.lua - name: Existing command, object and background owners run: | luajit tests/engine/gen2_object_runtime.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 702c0a97a..2b90a1ee9 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 Gen2Vanish = require('src.battle.Gen2VanishPresentation') -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -2738,7 +2739,10 @@ function BattleState:updateQueue() end if self.animPlaying then self.animPlayer:update() - if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').sync(self) end + if GameVersion.isGen2() then + require('src.battle.Gen2PicturePresentation').sync(self) + Gen2Vanish.sync(self) + end if self.animPlayer.pollEffects and self.applyAnimEffect then for _, ev in ipairs(self.animPlayer:pollEffects()) do self:applyAnimEffect(ev) @@ -2778,7 +2782,10 @@ function BattleState:updateQueue() return true end if not self.current then - if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').sync(self) end + if GameVersion.isGen2() then + require('src.battle.Gen2PicturePresentation').sync(self) + Gen2Vanish.sync(self) + end local item = table.remove(self.queue, 1) if not item then return false end if item.fn then @@ -3025,6 +3032,7 @@ function BattleState:updateQueue() end end if GameVersion.isGen2() then + Gen2Vanish.admitted(self,item,self.animPlaying and self.animPlayer.runtime or nil) require('src.battle.Gen2PicturePresentation').admitted(self,item, self.animPlaying and self.animPlayer.runtime or nil) end @@ -8282,6 +8290,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- charge release? local releasing = user.charging == moveInst and user.chargeReady if releasing then + Gen2Vanish.releasing(self,user) user.charging, user.chargeReady, user.invulnerable = nil, nil, nil user.hiddenAs = nil end @@ -8359,6 +8368,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima attackerPosition = user.position, targetPosition = target and target.position, animContext = animContext } + if GameVersion.isGen2() and releasing then Gen2Vanish.releaseRow(self,user,self.moveAnimRow) end table.insert(self.queue, self.nextInsert, self.moveAnimRow) end end @@ -8417,7 +8427,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- straight past `charge` while the sun is up and fires on the turn it was -- selected. Every other charge move ignores the weather. if record and record.charge and not releasing and not skipCharge then - local branchAware = GameVersion.get() == "firered" + local branchAware = GameVersion.isGen2() or GameVersion.get() == "firered" and hasMoveTurnVariant(move.anim) if not branchAware then self:cancelMoveAnim() end user.charging = moveInst @@ -8434,6 +8444,12 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- (a BW record names its own place: underwater, underground, vanished) user.hiddenAs = move.hiddenAs or ((move.id == "DIG") and "underground" or "air") end + if GameVersion.isGen2() then + -- BattleCommand_Charge runs this move's own param-1 program before + -- DisappearUser; Gen1's TELEPORT/SLIDE_DOWN substitutes do not apply. + if self.moveAnimRow then self.moveAnimRow.animParam=1 end + Gen2Vanish.charge(self,user,self.moveAnimRow) + end if not branchAware then local chargeAnim = record.charge.anim if move.id == "DIG" then @@ -13087,5 +13103,6 @@ require('src.battle.Gen2BgPictures').install(BattleState) require('src.battle.Gen2PicturePresentation').install(BattleState) require('src.battle.Gen2PictureArt').install(BattleState,{load=getImage,palette=monPalette,path=imagePathOf, source=function(image)return imageMeta[image]end}) +Gen2Vanish.install(BattleState) return BattleState diff --git a/src/battle/Gen2MovePresentation.lua b/src/battle/Gen2MovePresentation.lua index 3a66fcf76..a8becf560 100644 --- a/src/battle/Gen2MovePresentation.lua +++ b/src/battle/Gen2MovePresentation.lua @@ -10,7 +10,11 @@ function M.start(screen,id,isPlayer,opts) local player=screen.animPlayer if not (Version.isGen2() and player and Data.isGen2(player.anims) and player:scriptFor(id)) then return false end - if player:start(id,isPlayer,opts)==false then return false end + -- CheckFlyDigStatus reads both actual battlers at animation admission. + -- A retained runner keeps this snapshot when the next move starts. + local options={};for key,value in pairs(opts or {})do options[key]=value end + options.flying={player=screen:isUnderground('player'),enemy=screen:isUnderground('enemy')} + if player:start(id,isPlayer,options)==false then return false end screen.animPlaying=true screen.animName,screen.animAttackerIsPlayer=id,isPlayer screen:resetPicFx() diff --git a/src/battle/Gen2VanishPresentation.lua b/src/battle/Gen2VanishPresentation.lua new file mode 100644 index 000000000..11678c9ef --- /dev/null +++ b/src/battle/Gen2VanishPresentation.lua @@ -0,0 +1,101 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Fly/Dig's cleared picture box follows the native animation queue. Combat +-- owns invulnerable/hiddenAs; this latch owns only when that picture is seen. +local Version=require('src.core.GameVersion') +local M={} +local function side(b)return b.isPlayer and 'player' or 'enemy'end + +local function vanished(b) + return b and b.invulnerable and (b.hiddenAs=='air' or b.hiddenAs=='underground') or false +end +local function current(screen,b,p) + return screen[side(b)]==b and b.mon==p.mon + and not b.fainted and (b.mon.hp or 0)>0 +end +local function queued(screen,row) + for _,item in ipairs(screen.queue or {})do if item==row then return true end end + return false +end +local function states(screen) + screen._gen2VanishPictures=screen._gen2VanishPictures or {} + return screen._gen2VanishPictures +end + +function M.charge(screen,b,row) + if not (Version.isGen2() and vanished(b))then return end + states(screen)[side(b)]={battler=b,mon=b.mon,row=row,phase=row and 'charge' or 'hidden',hidden=not row} +end + +-- Native resolution clears the charge flags before announcing the release. +-- Keep an already-cleared box until that row's SHOW_MON, not until its text. +function M.releasing(screen,b) + if not Version.isGen2()then return end + local all=screen._gen2VanishPictures + local p=all and all[side(b)] + if not p then return end + if not (p.battler==b and current(screen,b,p) and p.hidden)then all[side(b)]=nil;return end + p.phase,p.row,p.runner='release',nil,nil +end +function M.releaseRow(screen,b,row) + local p=screen._gen2VanishPictures and screen._gen2VanishPictures[side(b)] + if p and p.battler==b and p.phase=='release'then p.row=row end +end + +function M.admitted(screen,row,runner) + for key,p in pairs(screen._gen2VanishPictures or {})do + local b=p.battler + if p.row==row then + p.row,p.runner=nil,runner + if not runner then + if p.phase=='charge' and vanished(b)then p.phase,p.hidden='hidden',true + else screen._gen2VanishPictures[key]=nil end + end + end + end +end + +-- One observation after the native VM tick; reads and draws never call this. +function M.sync(screen) + local all=screen._gen2VanishPictures + for key,p in pairs(all or {})do + local b=p.battler + local remove=not current(screen,b,p) + if p.phase=='hidden'then remove=remove or not vanished(b) + elseif p.runner then + local r=p.runner + local ended=r~=screen.animPlayer.runtime or r.stopped + if p.phase=='charge'then + if ended then + if vanished(b)then p.phase,p.hidden,p.runner='hidden',true,nil + else remove=true end + end + elseif ended or r.bg.picSize[key]~=nil then remove=true end + elseif not (p.row and queued(screen,p.row))then remove=true end + if remove then all[key]=nil end + end +end + +function M.hidden(screen,b) + local p=b and screen._gen2VanishPictures and screen._gen2VanishPictures[side(b)] + return p and p.battler==b and current(screen,b,p) and p.hidden + and (p.phase=='release' or vanished(b)) or false +end +function M.install(Screen) + function Screen:isUnderground(side,mon) + local b=(side=='player' or side=='enemy')and self[side] + if not b or mon and mon~=b and mon~=b.mon then return false end + return vanished(b) and M.hidden(self,b) or false + end + local hidden=Screen.fxHidden + function Screen:fxHidden(b) + return Version.isGen2() and M.hidden(self,b) or hidden(self,b) + end + local read=Screen.animPicState + function Screen:animPicState(side) + local state=read and read(self,side) + local b=(side=='player' or side=='enemy')and self[side] + if Version.isGen2() and M.hidden(self,b)then state=state or {};state.hidden=true end + return state + end +end +return M diff --git a/tests/engine/gen2_vanish_presentation.lua b/tests/engine/gen2_vanish_presentation.lua new file mode 100644 index 000000000..0fec31e8f --- /dev/null +++ b/tests/engine/gen2_vanish_presentation.lua @@ -0,0 +1,203 @@ +-- Real native move resolution -> queue -> VM -> picture/BG observations. +-- Programs are generated fixtures, not cartridge data or modified mods. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Catalog=require('src.battle.Gen2AnimCatalog') +local previous=Version.get() +local function make(edition,format) + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=Version.generation() + for _,id in ipairs{'FLY','DIG','SOLARBEAM','PROBE'}do + data.moves[id]={id=id,name=id,type='NORMAL',power=1,accuracy=100,accuracyRaw=255,pp=10, + effect=id=='PROBE'and 'NO_ADDITIONAL_EFFECT' or id=='SOLARBEAM'and 'CHARGE_EFFECT' or 'FLY_EFFECT'} + end + local main={entry=1,code={ + {op='ifparamequal',args={1},to=7}, + {op='delay',frames=3}, + {op='bgeffect',args={'BATTLE_BG_EFFECT_SHOW_MON',0,1,0}}, + {op='delay',frames=8},{op='ret'},{op='ret'}, + {op='delay',frames=3}, + {op='bgeffect',args={'BATTLE_BG_EFFECT_REMOVE_MON',0,1,0}}, + {op='delay',frames=45},{op='ret'}, + }} + local probe={entry=1,code={ + {op='bgeffect',args={'BATTLE_BG_EFFECT_SHOW_MON',0,0,0}}, + {op='bgeffect',args={'BATTLE_BG_EFFECT_BATTLEROBJ_1ROW',0,0,0}}, + {op='delay',frames=8},{op='ret'}, + }} + local anims={gen2=true,moveAnims={FLY=1,DIG=1,SOLARBEAM=1,PROBE=2},scripts={[1]=main,[2]=probe}, + objects={},framesets={},oam={},gfx={}} + anims.functions={[0]='Null'} + anims.framesets[0]={frames={{oam=0,duration=1}},loop='restart'} + anims.oam[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}} + for index,name in ipairs(require('src.battle.Gen2AnimObjectIds').battleAnimObjectOrder)do + if name=='BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW' or name=='BATTLE_ANIM_OBJ_ENEMYFEET_1ROW'then + anims.objects[index-1]={oamFlags=0,fixY=255,fn=0,frameset=0,palette=0,gfx=0} + end + end + if format=='native'then + anims=Catalog.compile(anims,nil,edition) + anims.gen2,anims.format,anims.moveAnims,anims.indices,anims.misc=nil,nil,nil,nil,nil + end + data.battle_anims=anims + require('src.render.Font').load(data) + local save=Save.newGame();save.options.textSpeed=1 + save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',18)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}, + input={wasPressed=function()return true end,isDown=function()return true end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s.introSlide,s.sendingOut,s.enemySendingOut=0,false,false + s.showPlayerBack,s.showEnemyTrainer=false,false + s.rngCalls=0;s.rng=function()s.rngCalls=s.rngCalls+1;return 0 end + return s +end +local function untilState(s,predicate,label) + local frames=0 + while not predicate() and frames<1500 do s:updateQueue();frames=frames+1 end + T.check(frames<1500,label) + return frames +end +local function drained(s) + return #s.queue==0 and not s.current and not s.animPlaying and (s.waitFrames or 0)<=0 + and not s.draining +end +local function finish(s)untilState(s,function()return drained(s)end,'native queue and VM release')end +local function underground(s,side,mon) + return s.isUnderground and s:isUnderground(side,mon) or false +end +local function release(s,id,side,move) + local p=s._gen2VanishPictures and s._gen2VanishPictures[side] + s:act(function() + s:performMove(s[side],s[side=='player'and'enemy'or'player'],move) + if id~='SOLARBEAM'then + T.eq(p and p.phase,'release','actual release retains the preceding cleared box') + T.eq(p and p.row,s.moveAnimRow,'release latch binds the real native row') + end + end) + untilState(s,function()return s.animPlaying end,'actual release program starts') + if id~='SOLARBEAM'then + T.eq(s._gen2VanishPictures[side],p,'release admission retains its original picture latch') + T.eq(p and p.runner,s.animPlayer.runtime,'release admission binds its actual VM') + end + T.eq(s.animPlayer.moveId,id,'release uses the actual move') + T.eq(s.animPlayer.vars.param,0,'release uses cartridge param zero') +end +for _,edition in ipairs{'gold','silver','crystal'}do + for _,format in ipairs{'flat','native'}do for _,side in ipairs{'player','enemy'}do + for _,id in ipairs{'FLY','DIG','SOLARBEAM'}do + local s=make(edition,format) + local b,target=s[side],s[side=='player'and'enemy'or'player'] + local move={id=id,pp=10};b.curMoves={move} + local hp=b.mon.hp + s:performMove(b,target,move) + T.eq(b.charging,move,'native first charge retains the original instance') + T.eq(move.pp,9,'native first charge spends exactly one PP') + T.eq(b.invulnerable==true,id~='SOLARBEAM','only real Fly/Dig charge hides the battler') + local rows={};for _,row in ipairs(s.queue)do if row.anim then rows[#rows+1]=row end end + T.eq(#rows,1,'charge has one animation, no legacy extra clock') + T.eq(rows[1].anim,id,'charge uses its own ROM program, never a Gen1 substitute') + T.eq(rows[1].animParam,1,'first charge selects the real param-1 arm') + untilState(s,function()return s.animPlaying end,'actual queued charge program starts') + T.eq(s.animPlayer.moveId,id,'native queue starts the move itself') + T.eq(s.animPlayer.vars.param,1,'actual interpreter sees charge parameter') + T.eq(underground(s,side),false,'takeoff/burrow still starts with a visible battler') + T.eq(s.animPlayer.runtime.env.flying[side],false,'first charge SHOW_MON can still use the battler') + local r=s.anim + finish(s) + local vanished=id~='SOLARBEAM' + T.eq(underground(s,side),vanished,'picture disappears only after native charge animation') + T.eq(s:animPicState(side).hidden,vanished,'actual public picture state follows the native latch') + local calls=s.rngCalls + for _=1,3 do underground(s,side);s:animPicState(side);if s.pic then s:pic(b.mon,false)endend + T.eq(s.rngCalls,calls,'picture reads never consume battle RNG') + T.eq(b.mon.hp,hp,'charge/picture reads retain native HP') + T.eq(move.pp,9,'picture reads never spend additional PP') + T.eq(r.env.flying[side],false,'retained first runner keeps its admission snapshot') + release(s,id,side,move) + T.eq(b.charging,nil,'native release clears its original charge instance') + T.eq(b.invulnerable,nil,'native release clears invulnerability before animation') + T.eq(move.pp,9,'native second turn spends no extra PP') + T.eq(s:animPicState(side).hidden,vanished,'release stays absent during pre-SHOW_MON wait') + T.eq(s.animPlayer.runtime.env.flying[side],false,'release SHOW_MON is admitted after real flag clears') + local current=s.anim + untilState(s,function()return current.bg.picSize[side]~=nil end,'real native SHOW_MON executes') + T.eq(s:animPicState(side).hidden,false,'SHOW_MON reveals the same native battler') + T.eq(current.frames,5,'reveal uses the VM wait, no invented presentation clock') + finish(s) + T.eq(underground(s,side),false,'release retires cleared picture latch') + T.eq(next(s._gen2VanishPictures or {}),nil,'completed native release retains no stale battler owner') + s.animPlayer:release() + end + -- A move aimed at a genuinely vanished target must not copy its battler + -- rows into OBJ or reveal the target's BG box (both read the same flags). + local s=make(edition,format) + local b=s[side];local other=side=='player'and'enemy'or'player' + local move={id='DIG',pp=10};b.curMoves={move} + s:performMove(b,s[other],move);finish(s) + T.check(underground(s,side,b.mon),'optional source mon resolves actual active slot') + T.eq(underground(s,side,{}),false,'an unrelated mon cannot borrow active volatile state') + T.check(s:animForMove('PROBE',other),'actual opposing native move starts') + local r=s.anim + T.eq(r.env.flying[side],true,'the real opposing VM sees the hidden target') + s:updateQueue();s:updateQueue();s:updateQueue() + T.eq(r.bg.picSize[side],nil,'CheckFlyDigStatus suppresses actual SHOW_MON') + T.eq(r.bg.liftedRows[side],nil,'hidden target contributes no lifted BG rows') + T.eq(r.objects:activeCount(),0,'hidden target contributes no battler-row OBJ') + finish(s) + b.invulnerable=nil;b.hiddenAs=nil;s:updateQueue() + T.eq(underground(s,side),false,'native cancellation removes visibility flag') + T.eq(r.env.flying[side],true,'retained VM snapshot never follows future combat flags') + T.check(s:animForMove('PROBE',other),'next opposing program has a fresh native runner') + T.eq(s.anim.env.flying[side],false,'fresh runner reads the cleared actual flag') + s:updateQueue();s:updateQueue() + T.check(s.anim.bg.picSize[side]~=nil,'on-field target admits actual SHOW_MON') + T.check(s.anim.objects:activeCount()>0,'on-field target admits real battler OBJ') + T.eq(r.env.flying[side],true,'starting next runner cannot rebind retained runner') + s.animPlayer:release() + end end +end +-- Disabled/missing programs, skipped/cancelled rows and replacement/faint +-- release only their own picture state; the move's combat result is native. +for _,mode in ipairs{'disabled','missing','suppressed','cancelled','switch','faint','replaced','missed-release'}do + local s=make('crystal','native');local b=s.player;local move={id='DIG',pp=10};b.curMoves={move} + if mode=='disabled'then s.game.save.options.animations=false end + if mode=='missing'then s.data.battle_anims.scripts={}end + s:performMove(b,s.enemy,move,false,mode=='suppressed') + if mode=='cancelled'then s:cancelMoveAnim()end + if mode=='replaced'then + untilState(s,function()return s.animPlaying end,'charge to replace starts') + s:animForMove('PROBE','enemy') + end + finish(s) + T.eq(underground(s,'player'),mode~='cancelled',mode..' follows its actual charge admission') + if mode=='switch'then + s:switchPlayerInto(Battle.POS.PLAYER_LEFT,s.game.save.party[2]);s:updateQueue() + T.eq(underground(s,'player'),false,'incoming battler never inherits old cleared box') + T.eq(next(s._gen2VanishPictures or {}),nil,'actual switch releases old native picture owner') + elseif mode=='faint'then + b.mon.hp=0;b.fainted=true;s:updateQueue() + T.eq(next(s._gen2VanishPictures or {}),nil,'actual faint releases its cleared picture owner') + else + if mode=='disabled'then s.game.save.options.animations=true end + if mode=='missed-release'then s.data.moves.DIG.accuracyRaw=0 end + s:act(function()s:performMove(b,s.enemy,move)end);finish(s) + T.eq(underground(s,'player'),false,mode..' releases the hidden box after the actual second turn') + T.eq(next(s._gen2VanishPictures or {}),nil,mode..' retains no stale presentation owner') + end + T.eq(move.pp,9,mode..' presentation preserves native PP continuation') + s.animPlayer:release() +end +-- Existing non-GSC charge animation selection is unchanged. +for _,edition in ipairs{'red','firered','emerald'}do + local s=make(edition,'flat');local move={id='DIG',pp=10} + s:performMove(s.player,s.enemy,move) + local animation;for _,row in ipairs(s.queue)do if row.anim then animation=row.anim end end + if edition=='red'then T.eq(animation,'SLIDE_DOWN_ANIM','Gen1 keeps original charge animation')end + T.eq(s._gen2VanishPictures,nil,edition..' never creates GSC presentation state') +end +Version.set(previous) +T.finish('native GSC Fly/Dig charge, release, VM status snapshots and picture ownership') From 9962abb5423f17283fb11cc9f46af80faea1ffa7 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:31:05 +0100 Subject: [PATCH 105/187] Adapt charge checks to upstream optional idle picture observations --- tests/engine/gen2_vanish_presentation.lua | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/engine/gen2_vanish_presentation.lua b/tests/engine/gen2_vanish_presentation.lua index 0fec31e8f..8a20c0e96 100644 --- a/tests/engine/gen2_vanish_presentation.lua +++ b/tests/engine/gen2_vanish_presentation.lua @@ -66,6 +66,9 @@ local function drained(s) and not s.draining end local function finish(s)untilState(s,function()return drained(s)end,'native queue and VM release')end +local function pictureHidden(s,side) + local state=s:animPicState(side);return state and state.hidden or false +end local function underground(s,side,mon) return s.isUnderground and s:isUnderground(side,mon) or false end @@ -110,9 +113,9 @@ for _,edition in ipairs{'gold','silver','crystal'}do finish(s) local vanished=id~='SOLARBEAM' T.eq(underground(s,side),vanished,'picture disappears only after native charge animation') - T.eq(s:animPicState(side).hidden,vanished,'actual public picture state follows the native latch') + T.eq(pictureHidden(s,side),vanished,'actual public picture state follows the native latch') local calls=s.rngCalls - for _=1,3 do underground(s,side);s:animPicState(side);if s.pic then s:pic(b.mon,false)endend + for _=1,3 do underground(s,side);s:animPicState(side);if s.pic then s:pic(b.mon,false)end end T.eq(s.rngCalls,calls,'picture reads never consume battle RNG') T.eq(b.mon.hp,hp,'charge/picture reads retain native HP') T.eq(move.pp,9,'picture reads never spend additional PP') @@ -121,11 +124,11 @@ for _,edition in ipairs{'gold','silver','crystal'}do T.eq(b.charging,nil,'native release clears its original charge instance') T.eq(b.invulnerable,nil,'native release clears invulnerability before animation') T.eq(move.pp,9,'native second turn spends no extra PP') - T.eq(s:animPicState(side).hidden,vanished,'release stays absent during pre-SHOW_MON wait') + T.eq(pictureHidden(s,side),vanished,'release stays absent during pre-SHOW_MON wait') T.eq(s.animPlayer.runtime.env.flying[side],false,'release SHOW_MON is admitted after real flag clears') local current=s.anim untilState(s,function()return current.bg.picSize[side]~=nil end,'real native SHOW_MON executes') - T.eq(s:animPicState(side).hidden,false,'SHOW_MON reveals the same native battler') + T.eq(pictureHidden(s,side),false,'SHOW_MON reveals the same native battler') T.eq(current.frames,5,'reveal uses the VM wait, no invented presentation clock') finish(s) T.eq(underground(s,side),false,'release retires cleared picture latch') From 6339b7dcaf476777bd427c87673c9b9d1d8ba1a4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:31:31 +0100 Subject: [PATCH 106/187] Document actual upstream queue integration and validation limits --- docs/native-gen2-vanish-presentation.md | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/native-gen2-vanish-presentation.md diff --git a/docs/native-gen2-vanish-presentation.md b/docs/native-gen2-vanish-presentation.md new file mode 100644 index 000000000..dd2070905 --- /dev/null +++ b/docs/native-gen2-vanish-presentation.md @@ -0,0 +1,65 @@ +# Native GSC Fly/Dig presentation + +The shared controller previously replaced a first charge with Gen1's +`TELEPORT` or `SLIDE_DOWN_ANIM`. Its genuine GSC interpreter accepted +`env.flying`, but actual move admission never bound either native battler. +`SHOW_MON` and the two battler-row OBJ effects could therefore treat an absent +Fly/Dig target as present. A cleared picture box also had no persistent native +owner between moves. + +## Result + +GSC charge moves retain their own native ROM animation row with parameter 1; +the release uses parameter 0. Existing damage, accuracy, PP, charge flags, +charge-required hooks and random draws retain their native owners. Gen1/GBA +charge selection is unchanged. + +`Gen2VanishPresentation` observes the actual native `invulnerable`/`hiddenAs` +state and the admitted row's genuine interpreter. The first takeoff/burrow +starts visible; only completion of that animation latches the cleared box. +Each new VM receives its own snapshot of both battlers through the native +`isUnderground(side, mon)` reader. A retained runner keeps its original flags. +The release holds an already-cleared box until its actual BG `SHOW_MON` +produces `picSize`, or until normal completion/fallback releases it. + +Cancelled/missing/disabled animations, replacement, switch and faint retire +only their own picture relationship. Native picture-state readers and the existing native drawer share the same +visibility. Reads never step an interpreter, spend PP +or consume RNG. The latch has at most one record per active side and does not +allocate work per draw. + +This upstream adaptation uses the existing native updateQueue owner. That +source tree has no semantic checkpoint service; none is invented here. The +fork has an additional checked integration with its existing checkpoint graph. + +## Reference and verification + +The timing follows the frozen Gen1Recomp reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, especially +`src/ui/gen2/BattleState.lua` (`isUnderground`, `startAnim`, `revealVanished`) +and its documented GSC `BattleCommand_Charge` / `CheckFlyDigStatus` behavior. +Existing Gen1Recomp/Cedric rights and interpreter provenance remain intact; +see `compat/gen1-gsc/` and `LICENSE-ROXAS2712.md`. Original contributions are +offered to Cedric / UNDERdecoded under the latter permission, including +visible credit; earlier grants and other authors' terms remain effective. + +The ROM-free `gen2_vanish_presentation` suite drives the actual shared native +controller, queue, command VM and BG/object pools for Gold, Silver and Crystal, +legacy and original source-native schemas, both attacking sides, both charge +moves and an ordinary visible charge. It also covers missing/disabled data, +cancel/replacement/switch/faint/missed release, retained runners, PP/RNG reads +and non-GSC isolation. `gen2_vanish_checkpoint` drives the actual public +semantic capture/restore and the restored second turn. + +A separate local actual-Crystal-ROM check runs both Fly/Dig halves through the +same controller with ROM-free test battlers. Actual ROM timings on both sides: +Fly charge 66 VM frames, Dig charge 179; release SHOW_MON at frame 34 and +completion at frame 67. A hidden-target Growl runs 170 VM frames with complete +command/object/OAM/BG state equal to the frozen reference receiving the same +flags. No ROM bytes, mod changes, saves or machine paths are committed. + +These are engine CPU/ROM-data checks. They do not qualify unchanged VASC, +visible OS windows, GPU output, Windows, performance or a packaged release. +The upstream adaptation uses its existing native queue; its source tree has +no semantic checkpoint service, so that fork-only service is neither invented +nor claimed there. From 14d1c98cc2982fd3d46366cd5251c7be6a83a3c8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:32:02 +0100 Subject: [PATCH 107/187] Verify upstream enemy PP policy without importing fork battle rules --- tests/engine/gen2_vanish_presentation.lua | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/engine/gen2_vanish_presentation.lua b/tests/engine/gen2_vanish_presentation.lua index 8a20c0e96..1ee5787ce 100644 --- a/tests/engine/gen2_vanish_presentation.lua +++ b/tests/engine/gen2_vanish_presentation.lua @@ -95,10 +95,12 @@ for _,edition in ipairs{'gold','silver','crystal'}do local s=make(edition,format) local b,target=s[side],s[side=='player'and'enemy'or'player'] local move={id=id,pp=10};b.curMoves={move} + local expectedPP=not b.isPlayer and s.kind~='link' and s.ruleset + and s.ruleset.enemyUnlimitedPP and 10 or 9 local hp=b.mon.hp s:performMove(b,target,move) T.eq(b.charging,move,'native first charge retains the original instance') - T.eq(move.pp,9,'native first charge spends exactly one PP') + T.eq(move.pp,expectedPP,'first charge keeps the native side PP policy') T.eq(b.invulnerable==true,id~='SOLARBEAM','only real Fly/Dig charge hides the battler') local rows={};for _,row in ipairs(s.queue)do if row.anim then rows[#rows+1]=row end end T.eq(#rows,1,'charge has one animation, no legacy extra clock') @@ -118,12 +120,12 @@ for _,edition in ipairs{'gold','silver','crystal'}do for _=1,3 do underground(s,side);s:animPicState(side);if s.pic then s:pic(b.mon,false)end end T.eq(s.rngCalls,calls,'picture reads never consume battle RNG') T.eq(b.mon.hp,hp,'charge/picture reads retain native HP') - T.eq(move.pp,9,'picture reads never spend additional PP') + T.eq(move.pp,expectedPP,'picture reads never spend additional PP') T.eq(r.env.flying[side],false,'retained first runner keeps its admission snapshot') release(s,id,side,move) T.eq(b.charging,nil,'native release clears its original charge instance') T.eq(b.invulnerable,nil,'native release clears invulnerability before animation') - T.eq(move.pp,9,'native second turn spends no extra PP') + T.eq(move.pp,expectedPP,'native second turn spends no extra PP') T.eq(pictureHidden(s,side),vanished,'release stays absent during pre-SHOW_MON wait') T.eq(s.animPlayer.runtime.env.flying[side],false,'release SHOW_MON is admitted after real flag clears') local current=s.anim From 5dacfd1363ac860a594f2695f1dda70ff3577f32 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:55:25 +0100 Subject: [PATCH 108/187] Use real GSC status IDs and structured native status presentation --- .../workflows/gen2-picture-presentation.yml | 1 + src/battle/BattleState.lua | 37 ++-- src/battle/Status.lua | 38 ++-- tests/engine/gen2_status_presentation.lua | 184 ++++++++++++++++++ 4 files changed, 233 insertions(+), 27 deletions(-) create mode 100644 tests/engine/gen2_status_presentation.lua diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml index e935b6660..f0e742c45 100644 --- a/.github/workflows/gen2-picture-presentation.yml +++ b/.github/workflows/gen2-picture-presentation.yml @@ -24,6 +24,7 @@ jobs: luajit tests/engine/gen2_battler_rows.lua luajit tests/engine/gen2_animation_catalog.lua luajit tests/engine/gen2_vanish_presentation.lua + luajit tests/engine/gen2_status_presentation.lua - name: Existing command, object and background owners run: | luajit tests/engine/gen2_object_runtime.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 2b90a1ee9..c94b17c5c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2861,7 +2861,10 @@ function BattleState:updateQueue() -- ball/send-out anims ignore the OPTIONS toggle: PlayMoveAnimation -- short-circuits to TossBallAnimation before its wOptions check -- (engine/battle/animations.asm:415) - if item.anim and (self:animationsOn() or BALL_ANIMS[item.anim]) then + local gen2Ids=GameVersion.isGen2() and self.animPlayer and self.animPlayer.anims + and self.animPlayer.anims.ids + local gen2Misc=gen2Ids and gen2Ids[item.anim] and not mdef + if item.anim and (self:animationsOn() or BALL_ANIMS[item.anim] or gen2Misc) then -- HOENN'S OWN ANIMATION, when the move has one. It does not take the -- animPlaying path: that one belongs to the Game Boy players, which -- carry their own per-row sounds, and this one does not -- so the @@ -2922,6 +2925,9 @@ function BattleState:updateQueue() ok, started = pcall(self.animForMove, self, item.anim, item.attackerIsPlayer and 'player' or 'enemy', item.animParam, item.effectiveness, item.afterAnim) + elseif gen2Misc and not opts then + ok,started=pcall(self.animForId,self,item.anim, + item.attackerIsPlayer and 'player' or 'enemy',item.animParam) else ok, started = pcall(require('src.battle.Gen2MovePresentation').start, self, item.anim, item.attackerIsPlayer, opts) @@ -8012,17 +8018,19 @@ end -- (core.asm): side-specific SLP_*/CONF_* anims, not the Rest/Amnesia -- move rows. Player sleep plays the anim before FastAsleepText; -- enemy sleep and both confusion sides print the text first. -function BattleState:statusOnomatopoeia(user, kind) +function BattleState:statusOnomatopoeia(user, kind, caption) local isPlayer = user.isPlayer local anim - if kind == "sleep" then + if GameVersion.isGen2() then + anim=kind=='sleep' and 'ANIM_SLP' or 'ANIM_CONFUSED' + elseif kind == "sleep" then anim = isPlayer and "SLP_PLAYER_ANIM" or "SLP_ANIM" else anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM" end - local text = kind == "sleep" + local text = caption or (kind == "sleep" and self:romText("_FastAsleepText", "%s\nis fast asleep!", displayName(user)) - or self:romText("_IsConfusedText", "%s\nis confused!", displayName(user)) + or self:romText("_IsConfusedText", "%s\nis confused!", displayName(user))) if kind == "sleep" and isPlayer then self:animNext(anim, isPlayer) self:sayNext(text) @@ -8032,14 +8040,12 @@ function BattleState:statusOnomatopoeia(user, kind) end end --- Queue status text (+ sleep/confusion FX when the line matches). +-- Queue status text and the effect belonging to this actual status event. -- Wake / snap-out / flinch / etc. stay text-only. -function BattleState:sayStatusMsg(user, msg) +function BattleState:sayStatusMsg(user, msg, kind) local text = prefixEnemy(msg, user) - if msg:find("is fast asleep!", 1, true) then - self:statusOnomatopoeia(user, "sleep") - elseif msg:find("is confused!", 1, true) then - self:statusOnomatopoeia(user, "confused") + if kind then + self:statusOnomatopoeia(user, kind, text) else self:sayNext(text) end @@ -8114,8 +8120,10 @@ function BattleState:statusInterrupt(user, target) end user.truantLoafing = true end - local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self) - for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end + local canMove,msgs,selfHit,reason,presentation=Status.beforeMove(user,self.rng,self) + for i,m in ipairs(msgs)do + self:sayStatusMsg(user,m,presentation and presentation.index==i and presentation.kind or nil) + end if selfHit then -- confusion self-hit (core.asm:3428-3434): clears everything in -- status1 except CONFUSED, then HandleSelfConfusionDamage deals a @@ -8136,8 +8144,7 @@ function BattleState:statusInterrupt(user, target) -- full paralysis (core.asm:3459-3464) clears bide/thrash/charge/ -- trapping; sleep, freeze, flinch and held-in-place leave every -- volatile in place (a sleeping wrapper keeps its victim held) - if user.mon.status == "PAR" and msgs[#msgs] - and msgs[#msgs]:find("fully paralyzed", 1, true) then + if presentation and presentation.blockedStatus=='PAR' then self:clearVolatiles(user, false) end return true diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 955361db0..8ef6b65c9 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -138,7 +138,7 @@ Status.RECORDS = { battler.mon.status = nil return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn end - return false, { Strings("%s\nis fast asleep!", name(battler)) } + return false, { Strings("%s\nis fast asleep!", name(battler)) }, nil, nil, "sleep" end, onInflict = function(battle, target, opts, display) target.sleepTurns = battle.rng(rule(battle, "sleepTurnsMin"), @@ -239,7 +239,9 @@ local function battleStatuses(battle) return battle and battle.data and battle.data.statuses end --- Returns canMove, messages, selfHit (true -> hurt itself in confusion). +-- Existing decision/messages/selfHit keep their native owners. Optional +-- registered reason/kind is retained in slot four; slot five describes the +-- actual presentation kind, message index and blocking status, never text. -- The active status record's beforeMove runs at its priority slot: above -- VOLATILE_PRIORITY before the held/disable/confusion block (sleep, -- freeze), at or below after it (paralysis) -- the original's order. @@ -260,20 +262,31 @@ function Status.beforeMove(battler, rng, battle) local handler = record and record.beforeMove local priority = handler and (record.beforeMovePriority or 0) local msgs = {} + local presentation + local function describe(kind,index,blocked) + if not (kind or blocked)then return end + presentation=presentation or {} + if kind then presentation.kind,presentation.index=kind,index end + if blocked then presentation.blockedStatus=blocked end + end local function runStatus() - local canMove, statusMsgs, selfHit = handler(battler, rng, battle) - for _, m in ipairs(statusMsgs or {}) do msgs[#msgs + 1] = m end - return canMove, selfHit + local statusId=mon.status + local canMove,statusMsgs,selfHit,reason,kind=handler(battler,rng,battle) + for _,m in ipairs(statusMsgs or {})do msgs[#msgs+1]=m end + if kind==nil and (reason=='sleep' or reason=='confused')then kind=reason end + if statusMsgs and #statusMsgs>0 and (kind=='sleep' or kind=='confused')then describe(kind,#msgs)end + if not canMove then describe(nil,nil,record.id or statusId)end + return canMove,selfHit,reason end if handler and priority > VOLATILE_PRIORITY then - local canMove, selfHit = runStatus() - if not canMove or selfHit then return canMove, msgs, selfHit end + local canMove,selfHit,reason=runStatus() + if not canMove or selfHit then return canMove,msgs,selfHit,reason,presentation end handler = nil end if battler.boundTurns and battler.boundTurns > 0 then battler.boundTurns = battler.boundTurns - 1 msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler)) - return false, msgs + return false,msgs,nil,nil,presentation end if battler.disabledTurns then battler.disabledTurns = battler.disabledTurns - 1 @@ -291,17 +304,18 @@ function Status.beforeMove(battler, rng, battle) table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler))) else table.insert(msgs, Strings("%s\nis confused!", name(battler))) + describe('confused',#msgs) -- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256) if rng(0, 255) < 128 then - return false, msgs, true -- hurt itself + return false,msgs,true,nil,presentation -- hurt itself end end end if handler then - local canMove, selfHit = runStatus() - if not canMove or selfHit then return canMove, msgs, selfHit end + local canMove,selfHit,reason=runStatus() + if not canMove or selfHit then return canMove,msgs,selfHit,reason,presentation end end - return true, msgs + return true,msgs,nil,nil,presentation end -- End-of-turn residual damage; opponent is needed for Leech Seed. diff --git a/tests/engine/gen2_status_presentation.lua b/tests/engine/gen2_status_presentation.lua new file mode 100644 index 000000000..fa65dbd79 --- /dev/null +++ b/tests/engine/gen2_status_presentation.lua @@ -0,0 +1,184 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Status=require('src.battle.Status') +local Strings=require('src.core.Strings') +local Catalog=require('src.battle.Gen2AnimCatalog') +local old=V.get() +local translation={ + ['%s\nis fast asleep!']='%s\nschlaeft tief.', + ['%s\nis confused!']='%s\nist verwirrt.', +} +local function make(edition,format,language) + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=V.generation() + data.battle_anims={gen2=true,scripts={ + [0x103]={entry=1,code={{op='delay',frames=3},{op='ret'}}}, + [0x104]={entry=1,code={{op='delay',frames=2},{op='ret'}}}, + },moveAnims={},objects={},framesets={},oam={},gfx={}} + require('src.battle.Gen2AnimationIds').install(data.battle_anims) + if format=='native'then + local a=Catalog.compile(data.battle_anims,nil,edition) + a.gen2,a.format,a.moveAnims,a.indices,a.misc=nil,nil,nil,nil,nil + data.battle_anims=a + end + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame();save.options.textSpeed=1 + save.party={Pokemon.new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}, + input={isDown=function()return true end,wasPressed=function()return true end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s.introSlide,s.sendingOut,s.enemySendingOut=0,false,false + s.showPlayerBack,s.showEnemyTrainer=false,false + Strings.load(language=='de' and {strings=translation}or {}) + s.rolls={};s.rng=function(lo,hi)s.rolls[#s.rolls+1]={lo,hi};return hi or lo end + return s +end +local function idle(s) + return #s.queue==0 and not s.current and not s.animPlaying and (s.waitFrames or 0)<=0 + and not s.draining +end +local function finish(s) + local frames=0;while not idle(s)and frames<1500 do s:updateQueue();frames=frames+1 end + T.check(frames<1500,'actual native status queue releases') +end +for _,edition in ipairs{'gold','silver','crystal'}do + for _,format in ipairs{'flat','native'}do for _,language in ipairs{'en','de'}do + for _,side in ipairs{'player','enemy'}do for _,kind in ipairs{'sleep','confused'}do + local s=make(edition,format,language);local b=s[side];local target=s[side=='player'and'enemy'or'player'] + if kind=='sleep'then b.mon.status='SLP';b.sleepTurns=3 else b.confusedTurns=3 end + local hp,pp=b.mon.hp,b.curMoves[1].pp + local interrupted=s:statusInterrupt(b,target,b.curMoves[1]) + T.eq(interrupted,kind=='sleep','presentation preserves actual native interrupt result') + T.eq(kind=='sleep'and b.sleepTurns or b.confusedTurns,2,'one actual native status-counter step') + T.eq(#s.rolls,kind=='sleep'and 0 or 1,'only real confusion consumes the existing roll') + if kind=='confused'then T.same(s.rolls[1],{0,255},'GSC native byte RNG ABI unchanged')end + T.eq(#s.queue,2,'one status line and one real animation') + local animation,text,index + for i,row in ipairs(s.queue)do + if row.anim then animation,index=row,i else text=row.text end + end + local expected=kind=='sleep'and 'ANIM_SLP'or 'ANIM_CONFUSED' + T.eq(animation and animation.anim,expected,'native GSC program identity, never a Gen1 substitute') + T.eq(animation and animation.attackerIsPlayer,side=='player','native status side owns mirroring') + T.eq(index,kind=='sleep'and side=='player'and 1 or 2,'cartridge status text/animation ordering') + T.check(text and text:find(language=='de'and(kind=='sleep'and'schlaeft'or'verwirrt') + or(kind=='sleep'and'fast asleep'or'is confused'),1,true),'actual translated caption is retained') + s.game.save.options.animations=false + local starts,admissions=0,0;local start=s.animPlayer.start + local admit=s.animForId + s.animForId=function(self,id,which,param) + admissions=admissions+1;T.eq(id,expected,'native row uses the real public misc entry point') + T.eq(which,side,'native misc entry point keeps the actual side') + return admit(self,id,which,param) + end + s.animPlayer.start=function(self,id,...) + starts=starts+1;T.eq(id,expected,'scene-off still admits the actual misc-status program') + return start(self,id,...) + end + local frames=0 + while not s.animPlaying and frames<1500 do s:updateQueue();frames=frames+1 end + T.check(frames<1500,'real native interpreter starts while ordinary move animations are off') + local runner=s.animPlayer.runtime + T.eq(runner.frames,0,'admission invents no VM frame') + T.eq(runner.animId,expected,'real interpreter exposes the actual status ID') + finish(s) + T.eq(starts,1,'status row starts exactly one interpreter') + T.eq(admissions,1,'native row is admitted through its public owner exactly once') + T.eq(runner.frames,kind=='sleep'and 4 or 5,'real native wait/ret owns duration') + T.eq(b.mon.hp,hp,'status presentation does not change HP') + T.eq(b.curMoves[1].pp,pp,'status presentation cannot spend PP') + T.eq(#s.rolls,kind=='sleep'and 0 or 1,'drawing/VM/status text consume no new battle RNG') + s.animPlayer:release() + end end + end end +end +-- The exact message index survives a later paralysis message. Neither the +-- animation nor interruption bookkeeping may be inferred from either text. +for _,language in ipairs{'en','de'}do + local s=make('crystal','native',language);local b=s.player + b.mon.status='PAR';b.confusedTurns=3;b.charging={};b.chargeReady=true;b.bideTurns=2 + local n=0;s.rng=function(lo,hi)n=n+1;return n==1 and 255 or 0 end + local interrupted=s:statusInterrupt(b,s.enemy,b.curMoves[1]) + T.eq(interrupted,true,'actual late paralysis blocks native action') + T.eq(n,2,'confusion and paralysis each use their existing roll once') + T.eq(b.charging,nil,'structured paralysis reason clears native charge in every language') + T.eq(b.bideTurns,nil,'structured paralysis reason clears native Bide') + T.eq(#s.queue,3,'confusion caption, program and separate paralysis line') + T.eq(s.queue[2].anim,'ANIM_CONFUSED','earlier exact confusion line owns the one animation') + T.check(s.queue[3].text~=s.queue[1].text,'later paralysis text is not replaced by confusion caption') + finish(s);s.animPlayer:release() +end +-- Misleading English words in another event cannot create an animation. +do + local s=make('crystal','flat','en');local b=s.player + s.data.statuses={SHAM={id='SHAM',beforeMovePriority=40,beforeMove=function() + return false,{'Other event is confused!'},false,'opaque-native-reason' + end}} + b.mon.status='SHAM' + local allowed,msgs,selfHit,reason,p=Status.beforeMove(b,s.rng,s) + T.eq(allowed,false);T.eq(reason,'opaque-native-reason','existing fourth native return value is preserved') + T.eq(p and p.kind,nil,'unrelated status supplies no invented presentation kind') + T.eq(p and p.blockedStatus,'SHAM','actual registered blocking status remains available') + s:sayStatusMsg(b,msgs[1]) + T.eq(#s.queue,1,'English-looking arbitrary text stays text') + T.eq(s.queue[1].anim,nil,'no animation inferred from English text') + s.animPlayer:release() +end +for _,mode in ipairs{'wake','snap','flinch','skip','missing'}do + local s=make('crystal','native','de');local b=s.player + if mode=='wake'then b.mon.status='SLP';b.sleepTurns=1 + elseif mode=='snap'then b.confusedTurns=1 + elseif mode=='flinch'then b.flinched=true + elseif mode=='skip'then b.skipMove=true + else b.mon.status='SLP';b.sleepTurns=3;s.data.battle_anims.scripts={}end + s:statusInterrupt(b,s.enemy,b.curMoves[1]) + local count=0;for _,row in ipairs(s.queue)do if row.anim then count=count+1 end end + T.eq(count,mode=='missing'and 1 or 0,mode..' has no false status program') + finish(s);T.check(not s.animPlaying,mode..' releases its native queue') + s.animPlayer:release() +end +do + local s=make('crystal','native','de');local b=s.player + b.confusedTurns=3;b.mon.status='PAR';b.charging={};b.bideTurns=2 + local calls=0;s.rng=function()calls=calls+1;return 0 end + local hp,pp=b.mon.hp,b.curMoves[1].pp + T.eq(s:statusInterrupt(b,s.enemy,b.curMoves[1]),true,'actual confusion self-hit blocks action') + T.eq(calls,1,'self-hit never makes a later paralysis roll') + T.check(b.mon.hp Date: Wed, 28 Oct 2026 06:58:57 +0100 Subject: [PATCH 109/187] Document adapted upstream status metadata and validation scope --- docs/native-gen2-status-presentation.md | 67 +++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 docs/native-gen2-status-presentation.md diff --git a/docs/native-gen2-status-presentation.md b/docs/native-gen2-status-presentation.md new file mode 100644 index 000000000..fb1cd2f76 --- /dev/null +++ b/docs/native-gen2-status-presentation.md @@ -0,0 +1,67 @@ +# Native GSC status presentation + +The native shared controller selected Gen1 SLP/CONF animation names for GSC. +Those programs do not exist in the GSC catalog. It also recognized a status by +English substrings in already-translated text, dropping the animation under a +translation or inventing one for unrelated text containing the same words. + +GSC sleep now selects ANIM_SLP; confusion selects ANIM_CONFUSED. Player sleep +runs before its caption, enemy sleep and both confusion sides after theirs. +The actual imported misc-ID table admits those rows even when ordinary move +animations are off, through the real public animForId entry point. Actual move +rows retain their existing option gate. Ball metadata retains its own path. + +The native status decision reports optional presentation metadata in its fifth +return value: kind, exact message index and actual blocking status. Existing +decision/message/self-hit values keep their native meaning; optional registered +reason/kind now propagates in slot four. Registered +Gen1-style handlers with an explicit kind in slot four are recognized while +retaining that value. The original caption is preserved. A later paralysis +line cannot replace the earlier confusion caption, and arbitrary English words +cannot create a status animation. + +The same structured blocker fixes localized full-paralysis cleanup: the +actual PAR handler, rather than the words in its message, owns the existing +native volatile cancellation. Counter order, damage arithmetic and random +rolls stay at their existing owners. The fork additionally marks its existing GBA Snore caption. This older +upstream tree has no Gen3SleepTalk module; no such implementation is imported +by this adaptation. Its original non-GSC native paths stay in place. + +## Sources + +The explicit event-kind/index approach is compared with Gen1Recomp's frozen +535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 src/battle/Status.lua and the shared +BattleState:sayStatusMsg boundary. The real GSC program names and side-specific +ordering are independently checked in [Crystal effect_commands.asm](https://github.com/pret/pokecrystal/blob/5c877ef55197039c9676e8e3eec6d254b1c40fe8/engine/battle/effect_commands.asm). +Pinned source blob: 0100aadbd8d8efe2495422d3209c9ffe9068533e. Relevant lines: +player sleep160/178, player confusion256/260, enemy sleep387/391 and enemy +confusion484/489. Existing Gen1Recomp/Cedric licences and credits remain intact; +see compat/gen1-gsc/ and LICENSE-ROXAS2712.md. + +## Verification and limits + +gen2_status_presentation drives the real native statusInterrupt, queue, public +misc admission and command VM for Gold/Silver/Crystal, both sides, old flat and +original source-native animation schemas, English/German captions and the +scene-off option. It covers late paralysis, actual confusion damage, exact +message ownership, explicit registered metadata, misleading text, wake/snap/ +flinch/skip/missing-program paths, PP/random draw preservation and non-GSC +animation identities. The fork also runs its existing GBA Sleep Talk/Disable/link/secondary suites +as regression coverage. The adapted upstream suite tests its own actual +controller and optional idle picture/queue boundaries independently. + +A separate local original-Crystal-ROM run uses ROM-free battlers with actual +ROM program data. Eight fork-native sleep/confusion cases (both sides, both +languages, ordinary move animations off) match the frozen reference's complete +command/object/OAM/BG state. The actual programs take198/98 VM frames; each +passes the public entry point once and adds no battle random draw beyond the +native confusion decision. Existing full428-program/ID comparison,2804 cases/ +355718 VM steps, picture data and native Fly/Dig checks still pass. + +Original-ROM evidence belongs to the fork; it is not an independent ROM run +on this adapted tree. Adapted source has its own ROM-free qualification. No new GPU, audible +output, unchanged-VASC visual, OS-window, Windows, performance or packaged +release acceptance is claimed. This change does not overhaul GSC's remaining +native sleep/recharge/turn rules; their separate gameplay audit remains open. +No mod, original ROM or player profile is changed, and no ROM bytes/art are +committed. From 98bbe7759740b6d63f794adcce310f9315b9843a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:24:37 +0100 Subject: [PATCH 110/187] Separate native GSC ordinary and animation SFX admission --- .../workflows/gen2-picture-presentation.yml | 1 + src/audio/Gen2Sfx.lua | 77 +++++++++++++++++++ src/battle/Gen2AnimPlayer.lua | 5 +- src/core/Sound.lua | 31 +++++++- 4 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 src/audio/Gen2Sfx.lua diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml index f0e742c45..476858fce 100644 --- a/.github/workflows/gen2-picture-presentation.yml +++ b/.github/workflows/gen2-picture-presentation.yml @@ -25,6 +25,7 @@ jobs: luajit tests/engine/gen2_animation_catalog.lua luajit tests/engine/gen2_vanish_presentation.lua luajit tests/engine/gen2_status_presentation.lua + luajit tests/engine/gen2_sfx_arbitration.lua - name: Existing command, object and background owners run: | luajit tests/engine/gen2_object_runtime.lua diff --git a/src/audio/Gen2Sfx.lua b/src/audio/Gen2Sfx.lua new file mode 100644 index 000000000..f1899e242 --- /dev/null +++ b/src/audio/Gen2Sfx.lua @@ -0,0 +1,77 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- GSC PlaySFX/PlayStereoSFX arbitration over the real native Sources. There +-- is no audio clock here: CheckSFX observes whether those Sources are playing. +local M={} +local Bus={};Bus.__index=Bus +local function native(def) + return type(def)=='table' and (def.engine=='gen2' or def.generation==2) +end +local function sameHeader(a,b) + return native(a) and native(b) and a.bank~=nil and a.address~=nil + and a.bank==b.bank and a.address==b.address +end +local function validId(id) + return type(id)=='number' and id==id and id>=0 and id<=65535 and id%1==0 +end + +-- The current importer supplies a sparse zero-based sfxIndex; original +-- source-native data supplies sfxOrder. Prefer an exact native label, then +-- the same aliased definition/header. Never guess an id from a sound's name. +function M.idFor(data,name,def) + if not native(def)then return end + local audio=data and data.audio or {} + local exact,alias + local function consider(id,label) + if not validId(id)then return end + if label==name then exact=math.max(exact or -1,id) + else + local other=audio.sfx and audio.sfx[label] + if other==def or sameHeader(other,def)then alias=math.max(alias or -1,id)end + end + end + if type(audio.sfxIndex)=='table'then + for id,label in pairs(audio.sfxIndex)do consider(tonumber(id),label)end + elseif type(audio.sfxOrder)=='table'then + for index,label in ipairs(audio.sfxOrder)do consider(index-1,label)end + end + return exact or alias +end +function M.new(stop) + return setmetatable({voices=setmetatable({},{__mode='k'}),current=0,stop=stop},Bus) +end +function Bus:clear() + for source in pairs(self.voices)do self.stop(source);self.voices[source]=nil end +end +function Bus:reset() + self:clear();self.current,self.audio=0,nil +end +function Bus:forget(name) + if name==nil then return self:reset()end + for source,row in pairs(self.voices)do if row.name==name then self.voices[source]=nil end end +end +function Bus:busy() + local busy=false + for source in pairs(self.voices)do + local ok,playing=pcall(source.isPlaying,source) + if ok and playing then busy=true else self.voices[source]=nil end + end + return busy +end +function Bus:play(data,name,def,stereo,mono,start) + local id=M.idFor(data,name,def) + if id==nil then return start()end + if self.audio~=data.audio then self:reset();self.audio=data.audio end + -- home/audio.asm:180-207 compares E with wCurSFX only while CheckSFX + -- reports an active channel. PlayStereoSFX never writes that byte. + if not stereo and self:busy() and self.current Date: Wed, 28 Oct 2026 07:25:03 +0100 Subject: [PATCH 111/187] Add real PCM and native GSC SFX integration checks --- tests/engine/gen2_sfx_arbitration.lua | 138 ++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/engine/gen2_sfx_arbitration.lua diff --git a/tests/engine/gen2_sfx_arbitration.lua b/tests/engine/gen2_sfx_arbitration.lua new file mode 100644 index 000000000..43b272fe1 --- /dev/null +++ b/tests/engine/gen2_sfx_arbitration.lua @@ -0,0 +1,138 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Real Sound -> ChipAudio -> GSC bytecode -> PCM and native animation VM. +-- Only the audio device is replaced; no playback clock is invented here. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Sound=require('src.core.Sound') +local Bus=require('src.audio.Gen2Sfx') +local Player=require('src.battle.Gen2AnimPlayer') +local oldVersion,oldAudio=V.get(),love.audio +local made={} +love.audio={newSource=function(pcm,kind) + local s={pcm=pcm,kind=kind,playing=false,starts=0,stops=0} + function s:play()self.playing=true;self.starts=self.starts+1 end + function s:stop()self.playing=false;self.stops=self.stops+1 end + function s:isPlaying()return self.playing end + function s:setVolume(v)self.volume=v end + function s:getVolume()return self.volume end + function s:setPitch(v)self.pitch=v end + function s:setLooping(v)self.looping=v end + function s:isLooping()return self.looping or false end + made[#made+1]=s + return s +end} +-- One channel-5 header and a real finite Gen2 square-note program, followed +-- by wave storage. These bytes are authored here, not extracted game data. +love.filesystem.write('sfx-probe.bin',string.char(4,3,0x40,3,0xf0,0,7,0xff)..string.rep('\0',0x4000-8)) +local function fixture(edition,shape) + V.set(edition);Sound.invalidate();Sound.applyOptions({stereo=true}) + local function header()return{bank=1,address=0x4000,engine='gen2'}end + local data={audio={programFile='sfx-probe.bin',programPrefix='',bankOrder={1}, + waveBanks={gen2={bank=1,address=0x4100,count=1}}, + sfx={HIGH=header(),MID=header(),LOW=header(),WRAPPED=header(),UNINDEXED=header(),FILE='custom.ogg'}, + songs={wind={file='weather.ogg'}},fanfares={}}} + if shape=='index'then data.audio.sfxIndex={['0']='HIGH',['5']='MID',['9']='LOW',['257']='WRAPPED'} + else data.audio.sfxOrder={'HIGH',false,false,false,false,'MID',false,false,false,'LOW'}end + data.audio.sfx.ALIAS=data.audio.sfx.MID + return data +end +for _,edition in ipairs{'gold','silver','crystal'}do + for _,shape in ipairs{'index','order'}do + local d=fixture(edition,shape) + T.eq(Bus.idFor(d,'MID',d.audio.sfx.MID),5,'exact native label wins over shared header aliases') + T.eq(Bus.idFor(d,'ALIAS',d.audio.sfx.ALIAS),shape=='index'and 257 or 9,'alias uses deterministic native header mapping') + T.eq(Bus.idFor(d,'FILE',d.audio.sfx.FILE),nil,'file effects never acquire guessed cartridge priority') + local low=Sound.play(d,'LOW') + T.check(low and low:isPlaying(),'native low-priority effect starts when channels are idle') + T.eq(low.kind,'static');T.eq(low.pcm:getChannelCount(),2) + T.check(low.pcm:getSampleCount()>441,'actual GSC interpreter produced an audible-length PCM buffer') + local energy=0 + for n=0,low.pcm:getSampleCount()-1 do + energy=energy+math.abs(low.pcm:getSample(n,1)) + if n%137==0 then T.eq(low.pcm:getSample(n,1),low.pcm:getSample(n,2),'existing two-channel PCM routing is retained')end + end + T.check(energy>0,'real program synthesis is non-silent') + local mid=Sound.play(d,'MID') + T.eq(low:isPlaying(),false,'ordinary PlaySFX clears preceding native channels') + local count=#made + T.eq(Sound.play(d,'LOW'),nil,'larger low-byte id is dropped while higher-priority SFX plays') + T.eq(#made,count,'dropped effect allocates no Source');T.eq(mid:isPlaying(),true) + local high=Sound.play(d,'HIGH') + T.eq(mid:isPlaying(),false);T.eq(high:isPlaying(),true,'accepted normal effect replaces the prior source') + local again=Sound.play(d,'HIGH') + T.eq(again,high);T.eq(high.starts,2,'equal priority restarts the existing native cache entry') + local stereo=Sound.playStereo(d,'LOW') + T.check(stereo and stereo:isPlaying(),'PlayStereoSFX bypasses normal priority') + T.eq(high:isPlaying(),true,'stereo entry preserves preceding static voices') + T.eq(Sound.play(d,'MID'),nil,'stereo entry does not overwrite wCurSFX priority byte') + high:stop() + T.eq(Sound.play(d,'MID'),nil,'CheckSFX sees the remaining actual stereo source') + stereo:stop() + mid=Sound.play(d,'MID') + T.check(mid and mid:isPlaying(),'real device completion releases the ordinary gate') + Sound.sfxChannelsOff() + T.eq(mid:isPlaying(),false) + local unindexed=Sound.play(d,'FILE') + high=Sound.play(d,'HIGH');T.eq(unindexed:isPlaying(),true,'non-GSC file definitions retain their existing owner') + Sound.invalidate('HIGH');T.eq(high:isPlaying(),false) + T.check(Sound.play(d,'LOW')~=nil,'partial invalidation forgets the removed active channel') + Sound.invalidate();Sound.applyOptions({stereo=false}) + mid=Sound.play(d,'MID');stereo=Sound.playStereo(d,'LOW') + T.eq(mid:isPlaying(),false,'mono PlayStereoSFX clears prior channels through its ungated entry') + T.check(stereo and stereo:isPlaying());T.eq(Sound.play(d,'LOW'),nil,'mono stereo entry still preserves current priority byte') + high=Sound.play(d,'HIGH');T.eq(stereo:isPlaying(),false) + Sound.suspend();T.eq(high:isPlaying(),false) + count=#made;T.eq(Sound.playStereo(d,'LOW'),nil);T.eq(#made,count,'suspended session cannot allocate or publish effects') + Sound.resume();T.check(Sound.play(d,'LOW')~=nil,'resume retires the old gate and recreates real playback') + Sound.invalidate();Sound.applyOptions({stereo=true}) + stereo=Sound.playStereo(d,'LOW') + T.eq(Sound.play(d,'MID'),nil,'initial wCurSFX zero is preserved by stereo playback') + stereo:stop();mid=Sound.play(d,'MID');mid:stop() + stereo=Sound.playStereo(d,'LOW') + T.eq(Sound.play(d,'LOW'),nil,'last normal priority persists after its source finishes') + if shape=='index'then + Sound.sfxChannelsOff();mid=Sound.play(d,'MID') + T.check(Sound.play(d,'WRAPPED')~=nil,'normal priority compares E, the low byte of a 16-bit native id') + T.eq(mid:isPlaying(),false) + end + -- The actual native player/command VM must use PlayStereoSFX too. + Sound.invalidate();high=Sound.play(d,'HIGH') + d.battle_anims={generation=2,oamsets={},scripts={probe={{'sound',0,9},{'wait',1},{'sound',0,5},{'ret'}}},moves={PROBE='probe'}} + local p=Player.new(d) + T.check(p:start('PROBE',true)) + local steps=0 + repeat p:update();steps=steps+1;T.check(steps<20)until p:isDone() + T.check(Sound.isPlaying('LOW'),'first native anim_sound survives a high-priority ordinary effect') + T.check(Sound.isPlaying('MID'),'second native anim_sound is admitted through the same actual VM') + T.eq(high:isPlaying(),true,'VM never routes animation sounds through ordinary channel clearing') + T.eq(Sound.play(d,'MID'),nil,'animation path never replaces ordinary current priority') + p:release();Sound.invalidate() + end +end +-- Native source lifecycle and public event observers, including reentrancy. +local d=fixture('crystal','index') +local run=T.sdk.loadMods({'mods/audio_probe'},{data=d,fs=T.sdk.memfs({ + ['mods/audio_probe/manifest.json']=[[{"id":"audio_probe","name":"Audio probe","version":"1.0.0","api":2,"entry":"main.lua"}]], + ['mods/audio_probe/main.lua']=[[ + local mod=... + mod.exports.events={} + mod.events:on('sound.played',function(e) + mod.exports.events[#mod.exports.events+1]=e.name + if mod.exports.callback then mod.exports.callback(e)end + end) + ]], +})}) +local probe=assert(run.loader.exports.audio_probe) +Sound.play(d,'HIGH');T.eq(#probe.events,1) +T.eq(Sound.play(d,'LOW'),nil);T.eq(#probe.events,1,'dropped effects emit no false sound.played event') +Sound.invalidate() +local nested +probe.callback=function(e)if e.name=='MID'then nested=Sound.play(d,'LOW')end end +local source=Sound.play(d,'MID') +T.eq(nested,nil,'reentrant callback observes committed normal priority') +T.eq(source:isPlaying(),true);T.eq(#probe.events,2,'reentrant denied effect emits nothing') +probe.callback=nil +require('src.mods.Loader').teardown();run.release() +Sound.invalidate();Sound.applyOptions({stereo=true});love.audio=oldAudio;V.set(oldVersion) +T.finish('native GSC normal/stereo SFX admission, real PCM, animation VM and playback ownership') From 50b5fb452d41354765ce09024dd54ba64770cee9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:25:59 +0100 Subject: [PATCH 112/187] Document GSC SFX adaptation and static-mixer limits --- docs/native-gen2-sfx-admission.md | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/native-gen2-sfx-admission.md diff --git a/docs/native-gen2-sfx-admission.md b/docs/native-gen2-sfx-admission.md new file mode 100644 index 000000000..62dbe7f87 --- /dev/null +++ b/docs/native-gen2-sfx-admission.md @@ -0,0 +1,49 @@ +# Native GSC SFX admission + +Normal `Sound.play` and battle-animation `Sound.playStereo` now have separate +native GSC admission. Previously both called the unrestricted static-source +path, so the fork had neither the ordinary priority rule nor an explicit +animation entry. The animation VM now uses the latter for indexed native SFX. + +The priority byte follows `PlaySFX` in `pret/pokecrystal` commit +`5c877ef55197039c9676e8e3eec6d254b1c40fe8`, `home/audio.asm:180–207`: +while an indexed native SFX source is playing, a larger low byte is refused; +equal or smaller replaces the native SFX group. Completion comes from the +actual Source rather than a second timer. `PlayStereoSFX` bypasses admission +and preserves the current byte; mono mode clears preceding indexed voices. +See also `audio/engine.asm:2472–2670` at the same revision. No assembly or +ROM-derived audio is included in this contribution. + +The native importer supplies sparse zero-based `sfxIndex` with `engine=gen2` +headers. The source-native `sfxOrder` format and `generation=2` headers are +also accepted. Exact labels take precedence over definition/header aliases; +no priority is guessed from an effect name. Non-GSC/file definitions retain +their previous playback path. Accepted playback is registered before public +sound events, The fork adaptation also revokes borrowed playback on ordinary preemption; +that separate lease API does not exist in this upstream baseline. +Invalidation and session suspension retire the same source owner. + +`tests/engine/gen2_sfx_arbitration.lua` exercises actual Sound, ChipAudio, +ChipSynth and the native animation VM in Gold, Silver and Crystal, with both +index schemas. The only device seam is `love.audio.newSource`; a finite GSC +bytecode program is genuinely synthesized into non-silent PCM. It checks +priority, low-byte comparison, completion, stereo/mono entry, alias mapping, +source reuse, public events, reentrancy and lifecycle. The fork-only lease +and weather assertions are absent here because those owners are not present +in this older upstream baseline. +The source-native VM also resolves sounds from the actual `sfxOrder`. + +## Limits of this change + +This adds SFX admission over the existing static PCM voices. It does not +implement a live, shared hardware-channel mixer: partial per-channel +replacement, cry-versus-SFX channel arbitration, animation panning/duration +and authentic stereo PCM remain separate work. Stereo overlapping voices +retain the current Gen1 reference behavior. The existing PCM renderer still +writes duplicated mono to the front stereo pair. CPU/device-seam checks do +not establish audible output, complete unchanged-VASC gameplay, Windows +execution, GPU/window acceptance or a release qualification. + +Only engine sources change. VASC, protected Gen1/player installations, +original ROMs and player saves are untouched. Contributions by Roxas2712; +see `LICENSE-ROXAS2712.md`, with existing third-party rights retained. From 1714ec5411665164e62bf833ab293ff27400d045 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:26:43 +0100 Subject: [PATCH 113/187] Clarify upstream audio owner scope --- docs/native-gen2-sfx-admission.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/native-gen2-sfx-admission.md b/docs/native-gen2-sfx-admission.md index 62dbe7f87..2f79b3d74 100644 --- a/docs/native-gen2-sfx-admission.md +++ b/docs/native-gen2-sfx-admission.md @@ -19,7 +19,7 @@ headers. The source-native `sfxOrder` format and `generation=2` headers are also accepted. Exact labels take precedence over definition/header aliases; no priority is guessed from an effect name. Non-GSC/file definitions retain their previous playback path. Accepted playback is registered before public -sound events, The fork adaptation also revokes borrowed playback on ordinary preemption; +sound events. The fork adaptation also revokes borrowed playback on ordinary preemption; that separate lease API does not exist in this upstream baseline. Invalidation and session suspension retire the same source owner. From 4f584d9c46f2c1cab2bb143dfd280f55282395b2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:56:39 +0100 Subject: [PATCH 114/187] Follow native GSC sleep turns in the upstream controller --- src/battle/BattleState.lua | 9 +- src/battle/Gen2Sleep.lua | 58 +++++++++++ src/battle/MoveEffects.lua | 1 + src/battle/Status.lua | 24 +++-- tests/engine/gen2_sleep_turns.lua | 168 ++++++++++++++++++++++++++++++ 5 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 src/battle/Gen2Sleep.lua create mode 100644 tests/engine/gen2_sleep_turns.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c94b17c5c..1b76d11cf 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -7938,8 +7938,9 @@ function BattleState:executeAction(user, target, action) if action.special == "recharge" then -- only reaching .HyperBeamCheck consumes the flag (core.asm:3384- -- 3392): sleep/freeze/held/flinch keep the mon recharging next turn - if self:preRechargeChecks(user, target) then return end + if not GameVersion.isGen2() and self:preRechargeChecks(user, target) then return end user.mustRecharge = nil + if GameVersion.isGen2()then require('src.battle.Gen2Sleep').cancel(user,action)end self:sayNext(self:romText("_MustRechargeText", "%s\nmust recharge!", displayName(user))) return end @@ -7964,7 +7965,7 @@ function BattleState:executeAction(user, target, action) return end - if self:statusInterrupt(user, target) then return end + if self:statusInterrupt(user, target, action) then return end -- PLATINUM / HGSS: Gravity and Heal Block refuse a move outright if self.g4fx and require("src.battle.Gen4MoveEffects").beforeMove(self, user, target, action) then return @@ -8106,7 +8107,7 @@ end -- Runs Status.beforeMove plus the shared interruption bookkeeping; -- returns true when the user's action is interrupted. -function BattleState:statusInterrupt(user, target) +function BattleState:statusInterrupt(user, target, move) -- TRUANT, before the conditions. SLAKING and SLAKOTH move every OTHER -- turn: the cartridge sets a flag after a move and reads it before the -- next one, which is why this is a toggle rather than "every second turn @@ -8120,7 +8121,7 @@ function BattleState:statusInterrupt(user, target) end user.truantLoafing = true end - local canMove,msgs,selfHit,reason,presentation=Status.beforeMove(user,self.rng,self) + local canMove,msgs,selfHit,reason,presentation=Status.beforeMove(user,self.rng,self,move) for i,m in ipairs(msgs)do self:sayStatusMsg(user,m,presentation and presentation.index==i and presentation.kind or nil) end diff --git a/src/battle/Gen2Sleep.lua b/src/battle/Gen2Sleep.lua new file mode 100644 index 000000000..4aa4b33cd --- /dev/null +++ b/src/battle/Gen2Sleep.lua @@ -0,0 +1,58 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- GSC sleep uses the live native battler/party state and the battle's RNG. +-- No parallel turn clock or presentation queue is maintained here. +local Strings=require('src.core.Strings') +local M={} +local function moveId(move) + if type(move)=='table'then return move.id end + return move +end + +-- CantMove also runs on waking. It cancels the locked sequences, including +-- the selected Fly/Dig's hidden flags, before the same turn continues. +function M.cancel(user,move) + local id=moveId(move)or user.charging and user.charging.id + user.bideTurns,user.bideDamage=nil,nil + user.thrashTurns,user.thrashMove,user.thrashAnnounced=nil,nil,nil + user.charging,user.chargeReady=nil,nil + user.rampRun,user.rampMove=nil,nil + if id=='FLY'or id=='DIG'then + user.invulnerable,user.hiddenAs=nil,nil + end +end + +function M.beforeMove(user,_,_,move) + local turns=(tonumber(user.sleepTurns)or tonumber(user.mon.sleepTurns)or 1)-1 + if turns<=0 then + user.mon.status,user.sleepTurns,user.mon.sleepTurns=nil,nil,nil + user.nightmare,user.expNightmare=nil,nil + M.cancel(user,move) + return true,{Strings('%s\nwoke up!',user.name)} + end + user.sleepTurns,user.mon.sleepTurns=turns,turns + local id=moveId(move) + local allowed=id=='SNORE'or id=='SLEEP_TALK' + if not allowed then M.cancel(user,move)end + return allowed,{Strings('%s\nis fast asleep!',user.name)},nil,nil,'sleep' +end + +function M.inflict(battle,user,low,high) + local rules=battle.ruleset or {} + local turns + if rules.sleepTurnsMin~=nil or rules.sleepTurnsMax~=nil then + turns=battle.rng(low,high) + else + -- SleepTarget masks a byte, rejects 0/7, then increments. The Tower + -- uses the two-bit mask; custom explicit rules retain their own range. + local mask=battle.inBattleTowerBattle and 4 or 8 + local value + repeat value=battle.rng(0,255)%mask until value~=0 and value~=7 + turns=value+1 + end + user.sleepTurns,user.mon.sleepTurns=turns,turns +end + +function M.rest(user) + user.sleepTurns,user.mon.sleepTurns=3,3 +end +return M diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 783c34151..6c5926619 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -288,6 +288,7 @@ MoveEffects.primary = { mon.hp = mon.stats.hp mon.status = "SLP" user.sleepTurns = 2 + if GameVersion.isGen2()then require('src.battle.Gen2Sleep').rest(user)end user.toxicCounter = nil return { Strings("%s\nstarted sleeping!", displayName(user)) } end diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 8ef6b65c9..74a210350 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -132,7 +132,10 @@ Status.RECORDS = { id = "SLP", label = "SLP", hudLabel = "SLP", catchBonus = 25, shakeBonus = 10, beforeMovePriority = 40, - beforeMove = function(battler) + beforeMove = function(battler,rng,battle,move) + if require('src.core.GameVersion').isGen2()then + return require('src.battle.Gen2Sleep').beforeMove(battler,rng,battle,move) + end battler.sleepTurns = (battler.sleepTurns or 1) - 1 if battler.sleepTurns <= 0 then battler.mon.status = nil @@ -141,8 +144,10 @@ Status.RECORDS = { return false, { Strings("%s\nis fast asleep!", name(battler)) }, nil, nil, "sleep" end, onInflict = function(battle, target, opts, display) - target.sleepTurns = battle.rng(rule(battle, "sleepTurnsMin"), - rule(battle, "sleepTurnsMax")) + local low,high=rule(battle,'sleepTurnsMin'),rule(battle,'sleepTurnsMax') + if require('src.core.GameVersion').isGen2()then + require('src.battle.Gen2Sleep').inflict(battle,target,low,high) + else target.sleepTurns=battle.rng(low,high)end return { Strings("%s\nfell asleep!", display) } end, }, @@ -245,7 +250,7 @@ end -- The active status record's beforeMove runs at its priority slot: above -- VOLATILE_PRIORITY before the held/disable/confusion block (sleep, -- freeze), at or below after it (paralysis) -- the original's order. -function Status.beforeMove(battler, rng, battle) +function Status.beforeMove(battler, rng, battle, move) local mon = battler.mon -- Haze curing this mon's sleep/freeze forfeits its pending move for -- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected @@ -254,7 +259,8 @@ function Status.beforeMove(battler, rng, battle) battler.skipMove = nil return false, {} end - if battler.flinched then + local gsc=require('src.core.GameVersion').isGen2() + if battler.flinched and not gsc then battler.flinched = false return false, { Strings("%s\nflinched!", name(battler)) } end @@ -271,7 +277,7 @@ function Status.beforeMove(battler, rng, battle) end local function runStatus() local statusId=mon.status - local canMove,statusMsgs,selfHit,reason,kind=handler(battler,rng,battle) + local canMove,statusMsgs,selfHit,reason,kind=handler(battler,rng,battle,move) for _,m in ipairs(statusMsgs or {})do msgs[#msgs+1]=m end if kind==nil and (reason=='sleep' or reason=='confused')then kind=reason end if statusMsgs and #statusMsgs>0 and (kind=='sleep' or kind=='confused')then describe(kind,#msgs)end @@ -283,6 +289,12 @@ function Status.beforeMove(battler, rng, battle) if not canMove or selfHit then return canMove,msgs,selfHit,reason,presentation end handler = nil end + if gsc and battler.flinched then + battler.flinched=false + msgs[#msgs+1]=Strings('%s\nflinched!',name(battler)) + require('src.battle.Gen2Sleep').cancel(battler,move) + return false,msgs,false,nil,presentation + end if battler.boundTurns and battler.boundTurns > 0 then battler.boundTurns = battler.boundTurns - 1 msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler)) diff --git a/tests/engine/gen2_sleep_turns.lua b/tests/engine/gen2_sleep_turns.lua new file mode 100644 index 000000000..951733907 --- /dev/null +++ b/tests/engine/gen2_sleep_turns.lua @@ -0,0 +1,168 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Actual native status records, infliction, action and move-effect owners. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Status=require('src.battle.Status') +local Registry=require('src.battle.StatusRegistry') +local Screen=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local old=V.get() +local function fixture(edition,id,side) + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=V.generation() + for key,definition in pairs({ + SNORE={id='SNORE',name='SNORE',index=173,power=40,type='NORMAL',accuracy=100,pp=15,effect='SNORE_EFFECT'}, + SLEEP_TALK={id='SLEEP_TALK',name='SLEEP TALK',index=214,power=0,type='NORMAL',accuracy=100,pp=10,effect='SLEEP_TALK_EFFECT'}, + REST={id='REST',name='REST',index=156,power=0,type='PSYCHIC',accuracy=100,pp=10,effect='HEAL_EFFECT'}, + })do data.moves[key]=definition end + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={Pokemon.new(data,'FIXMON_A',30)} + local s=Screen.newWild({data=data,save=save,stack={top=function()end}, + input={isDown=function()return true end,wasPressed=function()return true end}},'FIXMON_C',30) + s.queue,s.current,s.waitFrames={},nil,nil + s.rng=function(lo,hi)return hi and lo+math.floor((hi-lo)/2)or lo end + local b=s[side or 'player'];local t=s[(side or 'player')=='player'and'enemy'or'player'] + b.curMoves={{id=id or 'FIX_TACKLE',pp=10},{id='FIX_TACKLE',pp=0}} + b.mon.moves=b.curMoves + return s,b,t +end +local function asleep(b,turns) + b.mon.status,b.sleepTurns,b.mon.sleepTurns='SLP',turns,turns +end +local function texts(s) + local out={};for _,row in ipairs(s.queue)do if row.text then out[#out+1]=row.text end end + return table.concat(out,'|') +end +for _,edition in ipairs{'gold','silver','crystal'}do + for _,side in ipairs{'player','enemy'}do + local s,b,t=fixture(edition,'FIX_TACKLE',side) + local ppCost=side=='player'and 1 or 0 + asleep(b,1);b.nightmare,b.expNightmare=true,true + local hp,pp=t.mon.hp,b.curMoves[1].pp + s:executeAction(b,t,b.curMoves[1]) + T.eq(b.mon.status,nil,'native wake clears persistent status') + T.eq(b.sleepTurns,nil);T.eq(b.mon.sleepTurns,nil) + T.eq(b.nightmare,nil);T.eq(b.expNightmare,nil) + T.eq(b.curMoves[1].pp,pp-ppCost,'Gen2 acts on the same turn it wakes') + T.check(t.mon.hp1 then expectedStatus,expectedTurns='SLP',turns-1 end + T.eq(b.mon.status,expectedStatus) + T.eq(b.sleepTurns,expectedTurns,'GSC sleep is processed before flinch') + T.eq(#msgs,2,'sleep/wake caption then flinch caption') + T.check(msgs[2]:find('flinched!',1,true)~=nil) + end + + s,b,t=fixture(edition,'FIX_TACKLE',side);asleep(b,3) + b.mustRecharge,b.flinched=true,true + local rolls=0;s.rng=function()rolls=rolls+1;return 0 end + s:executeAction(b,t,{special='recharge'}) + T.eq(b.mustRecharge,nil,'GSC recharge is consumed before sleep/flinch') + T.eq(b.sleepTurns,3);T.eq(b.mon.sleepTurns,3,'recharge does not advance a second sleep clock') + T.eq(b.flinched,true,'recharge never consumes the later flinch check') + T.eq(rolls,0);T.check(texts(s):find('must recharge!',1,true)~=nil) + end + + -- Exercise actual StatusRegistry infliction for every possible ROM byte, + -- including zero/seven rejection and the Tower's two-bit mask. + for _,tower in ipairs{false,true}do + local s,b=fixture(edition);s.inBattleTowerBattle=tower + for byte=0,255 do + b.mon.status,b.sleepTurns,b.mon.sleepTurns=nil,nil,nil + local calls=0 + s.rng=function(lo,hi) + calls=calls+1;T.eq(lo,0);T.eq(hi,255,'native sleep reads a full battle byte') + return calls==1 and byte or 1 + end + local msgs=Registry.inflict(s,b,'SLP',{}) + local value=byte%(tower and 4 or 8) + local rejected=value==0 or value==7 + local expected=rejected and 2 or value+1 + T.eq(b.sleepTurns,expected,'actual byte/mask/rejection determines sleep duration') + T.eq(b.mon.sleepTurns,expected,'party counter is owned by the same native infliction') + T.eq(calls,rejected and 2 or 1,'rejected byte consumes another real battle draw') + T.eq(#msgs,1) + end + end + local s,b=fixture(edition);s.ruleset={sleepTurnsMin=3,sleepTurnsMax=9} + s.rng=function(lo,hi)T.eq(lo,3);T.eq(hi,9);return 9 end + Registry.inflict(s,b,'SLP',{});T.eq(b.sleepTurns,9);T.eq(b.mon.sleepTurns,9,'explicit custom sleep rules retain their own range') + + s,b=fixture(edition,'REST');b.mon.hp=math.floor(b.mon.stats.hp/2) + s:executeAction(b,s.enemy,b.curMoves[1]) + T.eq(b.mon.hp,b.mon.stats.hp);T.eq(b.sleepTurns,3);T.eq(b.mon.sleepTurns,3,'Rest initializes two lost turns plus waking turn') + for turn=1,2 do + local can=Status.beforeMove(b,s.rng,s,{id='FIX_TACKLE'}) + T.eq(can,false);T.eq(b.sleepTurns,3-turn) + end + T.eq(Status.beforeMove(b,s.rng,s,{id='FIX_TACKLE'}),true,'Rest wakes and acts on the third subsequent attempt') + s,b=fixture(edition);asleep(b,3);Status.beforeMove(b,s.rng,s,b.curMoves[1]) + local nextBattle=Screen.newWild(s.game,'FIXMON_C',30) + T.eq(nextBattle.player.mon,b.mon,'reconstruction retains the actual party Pokemon') + T.eq(nextBattle.player.sleepTurns,2,'new native wrapper reads the remaining party sleep counter') +end + +-- Shared native record behavior must remain unchanged outside Gen2. +for _,edition in ipairs{'red','yellow','emerald','firered'}do + local s,b=fixture(edition);asleep(b,1) + local allowed=Status.beforeMove(b,s.rng,s,b.curMoves[1]) + T.eq(allowed,false,'other-generation wake behavior retains the upstream baseline') + s,b=fixture(edition,'SNORE');asleep(b,3);b.flinched=true + local can=Status.beforeMove(b,s.rng,s,b.curMoves[1]) + T.eq(can,false) + T.eq(b.sleepTurns,3,'other-generation flinch/status order retains the upstream baseline') +end +V.set('crystal') +local s,b=fixture('crystal');asleep(b,3) +local custom={id='SLP',beforeMovePriority=40,beforeMove=function() + return true,{'custom status'},false,'custom-native-result' +end} +s.data.statuses={SLP=custom} +local can,msgs,selfHit,reason=Status.beforeMove(b,s.rng,s,b.curMoves[1]) +T.eq(can,true);T.eq(msgs[1],'custom status');T.eq(selfHit,nil);T.eq(reason,nil) +T.eq(b.sleepTurns,3,'registered custom status retains its actual handler and counter') +V.set(old) +T.finish('native GSC sleep duration, wake/bypass admission, ordering, Rest and persistent counter') From e714a978c185f28eee625127da497c544499a1cc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:57:39 +0100 Subject: [PATCH 115/187] Retain upstream enemy PP policy in sleep regression assertions --- tests/engine/gen2_sleep_turns.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/engine/gen2_sleep_turns.lua b/tests/engine/gen2_sleep_turns.lua index 951733907..ecf6fec67 100644 --- a/tests/engine/gen2_sleep_turns.lua +++ b/tests/engine/gen2_sleep_turns.lua @@ -79,7 +79,7 @@ for _,edition in ipairs{'gold','silver','crystal'}do -- The move's own awake guard still runs after a waking status check. s,b,t=fixture(edition,id,side);asleep(b,1) hp,pp=t.mon.hp,b.curMoves[1].pp;s:executeAction(b,t,b.curMoves[1]) - T.eq(b.mon.status,nil);T.eq(b.curMoves[1].pp,pp-1) + T.eq(b.mon.status,nil);T.eq(b.curMoves[1].pp,pp-ppCost) T.eq(t.mon.hp,hp,'wake permits the selected move, whose awake-only failure remains native') T.check(texts(s):find('failed!',1,true)~=nil) end From 82b0f66dd48480612deaebb84e7a5ff1076ea0ab Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:01:49 +0100 Subject: [PATCH 116/187] Qualify and document the upstream GSC sleep adaptation --- .../workflows/gen2-picture-presentation.yml | 1 + docs/native-gen2-sleep-turns.md | 49 +++++++++++++++++++ tests/engine/gen2_sleep_turns.lua | 3 ++ 3 files changed, 53 insertions(+) create mode 100644 docs/native-gen2-sleep-turns.md diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml index 476858fce..a4266f455 100644 --- a/.github/workflows/gen2-picture-presentation.yml +++ b/.github/workflows/gen2-picture-presentation.yml @@ -26,6 +26,7 @@ jobs: luajit tests/engine/gen2_vanish_presentation.lua luajit tests/engine/gen2_status_presentation.lua luajit tests/engine/gen2_sfx_arbitration.lua + luajit tests/engine/gen2_sleep_turns.lua - name: Existing command, object and background owners run: | luajit tests/engine/gen2_object_runtime.lua diff --git a/docs/native-gen2-sleep-turns.md b/docs/native-gen2-sleep-turns.md new file mode 100644 index 000000000..b4d47e960 --- /dev/null +++ b/docs/native-gen2-sleep-turns.md @@ -0,0 +1,49 @@ +# Native GSC sleep lifetime and turn admission + +The shared controller previously lost the waking turn in Gen2, blocked +Snore/Sleep Talk during sleep, processed flinch before sleep, and applied +Gen1's pre-recharge status checks. Its default sleep roll included a one-turn +counter, and the party Pokemon did not receive the remaining counter. + +`Gen2Sleep` now uses the actual native battler, party Pokemon and battle RNG: + +- A waking Pokemon acts that turn; Nightmare and the completed counter clear. +- Sleeping Snore/Sleep Talk reaches the existing move-effect owner. Each + move's own failure when awake still runs after waking. +- Sleep precedes flinch; mandatory recharge precedes both and does not tick + sleep. CantMove cancels Bide/rampage/charge/Rollout/Fury Cutter state and + the selected Fly/Dig flags. The existing picture owner follows those flags. +- Default SleepTarget draws a full byte, masks and rejects zero/seven before + incrementing: counters 2–7. The Tower flag selects the two-bit mask. An + explicitly configured sleep range and custom status handler remain owned + by their existing records. +- Each tick updates the existing party sleep counter, so reconstruction + reads the remaining count. Rest initializes three, allowing two blocked + attempts before waking. No second turn or presentation clock is added. + +Source: `pret/pokecrystal` commit +`5c877ef55197039c9676e8e3eec6d254b1c40fe8`, +`engine/battle/effect_commands.asm`: CheckTurn 140–194, enemy twin 370–422, +CantMove 333–356, SleepTarget 3571–3625, Heal/Rest 6032–6080. +The frozen Gen1 GSC `CrystalRules.canAct` and sleep record provide the second +engine reference. No assembly or ROM-derived program is included here. + +Tests use actual StatusRegistry, Status, native action/move dispatch and +picture ownership, both sides in Gold/Silver/Crystal. Every possible input +byte and both masks are checked, including rejected draws. They also cover +explicit status/range overrides, Rest, awake failure, charge cleanup, +party reconstruction and shared Gen1/GBA regressions. The fork has a separate semantic checkpoint test; that newer checkpoint +fixture/interface is absent from this upstream baseline and is not copied. +The older upstream enemy-PP policy and Gen1/GBA wake/flinch behavior retain +their native baseline; the adapted assertions explicitly reflect those owners. + +This fixes sleep lifetime/admission. Sleep Talk's older generic choice pool, +exact called-move history, Snore's remaining secondary-effect details and +other GSC status/turn-rule differences remain separate work. A conditional +Tower flag test does not establish complete native Battle Tower gameplay. +CPU checks establish neither visible/audible output nor whole unchanged-VASC, +Windows or release qualification. Mods, protected Gen1/player installations, +original ROMs and player saves are unchanged. + +Original contributions by Roxas2712; see `LICENSE-ROXAS2712.md`. Existing +source rights and notices remain effective. diff --git a/tests/engine/gen2_sleep_turns.lua b/tests/engine/gen2_sleep_turns.lua index ecf6fec67..c18b1ed66 100644 --- a/tests/engine/gen2_sleep_turns.lua +++ b/tests/engine/gen2_sleep_turns.lua @@ -54,10 +54,13 @@ for _,edition in ipairs{'gold','silver','crystal'}do b.charging={id='FLY'};b.chargeReady=true;b.invulnerable=true;b.hiddenAs='air' b.bideTurns,b.bideDamage,b.thrashTurns,b.rampRun=2,8,2,3 b.rampMove='ROLLOUT' + require('src.battle.Gen2VanishPresentation').charge(s,b,nil) + T.eq(s:fxHidden(b),true,'real native picture owner observes a charged Fly') local allowed=Status.beforeMove(b,s.rng,s,{id='FLY'}) T.eq(allowed,false);T.eq(b.sleepTurns,2);T.eq(b.mon.sleepTurns,2) T.eq(b.charging,nil);T.eq(b.chargeReady,nil);T.eq(b.invulnerable,nil);T.eq(b.hiddenAs,nil) T.eq(b.bideTurns,nil);T.eq(b.bideDamage,nil);T.eq(b.thrashTurns,nil);T.eq(b.rampRun,nil) + T.eq(s:fxHidden(b),false,'cancelled Fly cannot leave the real native picture hidden') s,b,t=fixture(edition,'FIX_TACKLE',side);asleep(b,3) pp,hp=b.curMoves[1].pp,t.mon.hp s:executeAction(b,t,b.curMoves[1]) From 86c9831d0f069d286460ead195f4cb4cd8fc42a0 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:16:30 +0100 Subject: [PATCH 117/187] Clear native GSC persistent counters at upstream item cure sites --- .../workflows/gen2-picture-presentation.yml | 1 + docs/native-gen2-sleep-turns.md | 2 + src/battle/HeldItems.lua | 1 + src/inventory/ItemEffects.lua | 9 ++- tests/engine/gen2_sleep_cures.lua | 56 +++++++++++++++++++ 5 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 tests/engine/gen2_sleep_cures.lua diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml index a4266f455..94d290381 100644 --- a/.github/workflows/gen2-picture-presentation.yml +++ b/.github/workflows/gen2-picture-presentation.yml @@ -27,6 +27,7 @@ jobs: luajit tests/engine/gen2_status_presentation.lua luajit tests/engine/gen2_sfx_arbitration.lua luajit tests/engine/gen2_sleep_turns.lua + luajit tests/engine/gen2_sleep_cures.lua - name: Existing command, object and background owners run: | luajit tests/engine/gen2_object_runtime.lua diff --git a/docs/native-gen2-sleep-turns.md b/docs/native-gen2-sleep-turns.md index b4d47e960..058f57454 100644 --- a/docs/native-gen2-sleep-turns.md +++ b/docs/native-gen2-sleep-turns.md @@ -20,6 +20,8 @@ counter, and the party Pokemon did not receive the remaining counter. - Each tick updates the existing party sleep counter, so reconstruction reads the remaining count. Rest initializes three, allowing two blocked attempts before waking. No second turn or presentation clock is added. +- Native held cures and medicine clear both party and active counters at the + existing cure site, including field medicine and revives. Source: `pret/pokecrystal` commit `5c877ef55197039c9676e8e3eec6d254b1c40fe8`, diff --git a/src/battle/HeldItems.lua b/src/battle/HeldItems.lua index 195f4ad0b..fd0767743 100644 --- a/src/battle/HeldItems.lua +++ b/src/battle/HeldItems.lua @@ -338,6 +338,7 @@ local function curePersistent(holder, wanted) if not status then return false end if wanted and norm(status) ~= wanted then return false end mon.status = nil + if require('src.core.GameVersion').isGen2()then mon.sleepTurns=nil end holder.sleepTurns, holder.toxicCounter = nil, nil return true end diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index c9119139b..99f6e829b 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -600,9 +600,14 @@ end -- `res BADLY_POISONED`); the raw w*ToxicCounter is NOT reset by item -- cures in Gen 1, so battle.sideToxic is deliberately left alone. local function cureActiveToxic(battle, target) + local gscSleep=require('src.core.GameVersion').isGen2()and target and target.status~='SLP' + if gscSleep then target.sleepTurns=nil end if not battle then return end for _, b in ipairs({ battle.player, battle.enemy }) do - if b and b.mon == target then b.toxicCounter = nil end + if b and b.mon == target then + b.toxicCounter = nil + if gscSleep then b.sleepTurns=nil end + end end end @@ -1028,6 +1033,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local function wake(mon) if mon and mon.status == "SLP" then mon.status = nil + cureActiveToxic(battle,mon) woke = true end end @@ -1212,6 +1218,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) return "failed", { Strings("It won't have\nany effect.") } end target.status = nil + cureActiveToxic(battle,target) target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp require("src.core.Sound").play(data, "Heal_HP") -- a revive takes the same .healHP -> .doneHealing route, animating up diff --git a/tests/engine/gen2_sleep_cures.lua b/tests/engine/gen2_sleep_cures.lua new file mode 100644 index 000000000..768071338 --- /dev/null +++ b/tests/engine/gen2_sleep_cures.lua @@ -0,0 +1,56 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Items=require('src.inventory.ItemEffects') +local Registry=require('src.battle.StatusRegistry') +local old=V.get() +local function fixture(edition) + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=V.generation() + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={Pokemon.new(data,'FIXMON_A',30)} + local s=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30) + s.queue={};s.rng=function()return 1 end + return s +end +local function sleep(b) + b.mon.status,b.mon.sleepTurns,b.sleepTurns='SLP',3,3 +end +for _,edition in ipairs{'gold','silver','crystal'}do + for _,side in ipairs{'player','enemy'}do + for _,effect in ipairs{13,15}do + local s=fixture(edition);local b=s[side] + s.data.items.PROBE_SLEEP_BERRY={name='Probe berry',heldEffect=effect,heldParam=0} + b.mon.item='PROBE_SLEEP_BERRY' + local msgs=Registry.inflict(s,b,'SLP',{}) + T.eq(b.mon.status,nil,'native held effect immediately cures inflicted sleep') + T.eq(b.mon.sleepTurns,nil,'held cure clears the party counter written by native infliction') + T.eq(b.sleepTurns,nil);T.eq(b.mon.item,nil,'actual held owner consumes the berry once') + T.eq(#msgs,2,'infliction and held cure preserve their native caption order') + end + for _,id in ipairs{'AWAKENING','FULL_HEAL','MINT_BERRY','FULL_RESTORE','REVIVE','MAX_REVIVE'}do + local s=fixture(edition);local b=s[side];sleep(b) + s.data.items[id]={name=id} + if id=='REVIVE'or id=='MAX_REVIVE'then b.mon.hp=0 end + local result=Items.use(s.data,s.game.save,id,b.mon,s) + T.eq(result,'consumed','actual native medicine succeeds') + T.eq(b.mon.status,nil);T.eq(b.mon.sleepTurns,nil,'medicine clears persistent sleep count') + T.eq(b.sleepTurns,nil,'same active wrapper is cleared immediately') + end + end + local s=fixture(edition);local mon=s.player.mon;sleep(s.player) + s.data.items.AWAKENING={name='AWAKENING'} + T.eq(Items.use(s.data,s.game.save,'AWAKENING',mon), 'consumed') + T.eq(mon.status,nil);T.eq(mon.sleepTurns,nil,'field medicine clears the actual party counter without a battle') +end +-- Gen1's unrelated dormant fields retain their previous shared-item behavior. +local s=fixture('red');local b=s.player;sleep(b) +s.data.items.AWAKENING={name='AWAKENING'} +T.eq(Items.use(s.data,s.game.save,'AWAKENING',b.mon,s),'consumed') +T.eq(b.mon.sleepTurns,3);T.eq(b.sleepTurns,3,'GSC counter cleanup does not reach Gen1') +V.set(old) +T.finish('native GSC held and medicine cures retire party and active sleep counters') From b60949e564de32666b9d21ab845b91b22ff01858 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:51:17 +0100 Subject: [PATCH 118/187] Publish canonical GSC species in native upstream data and gameplay --- .github/workflows/gen2-species-runtime.yml | 17 ++ docs/native-gen2-species-runtime.md | 41 ++++ src/core/Data.lua | 2 + src/core/Gen2SpeciesNames.lua | 211 +++++++++++++++++++++ src/core/SaveData.lua | 29 ++- src/mods/Loader.lua | 1 + src/mods/Registry.lua | 13 ++ src/mods/Schemas.lua | 2 + src/pokemon/DayCare.lua | 15 +- src/pokemon/Gen2Breeding.lua | 2 +- src/pokemon/Pokemon.lua | 1 + src/script/Gen2Commands.lua | 40 ++-- src/script/Gen2ScriptVM.lua | 15 +- src/script/Gen2Specials.lua | 26 +-- src/world/NPC.lua | 2 +- src/world/OverworldController.lua | 2 +- tests/gsc_species_fixture.lua | 19 ++ 17 files changed, 384 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/gen2-species-runtime.yml create mode 100644 docs/native-gen2-species-runtime.md create mode 100644 src/core/Gen2SpeciesNames.lua create mode 100644 tests/gsc_species_fixture.lua diff --git a/.github/workflows/gen2-species-runtime.yml b/.github/workflows/gen2-species-runtime.yml new file mode 100644 index 000000000..f2ca48429 --- /dev/null +++ b/.github/workflows/gen2-species-runtime.yml @@ -0,0 +1,17 @@ +name: Native GSC runtime identities +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - run: sudo apt-get update && sudo apt-get install -y luajit + - name: Native graph, old saves and script/breeding identities + run: | + luajit tests/engine/gen2_species_runtime.lua + luajit tests/engine/gen2_species_gameplay.lua diff --git a/docs/native-gen2-species-runtime.md b/docs/native-gen2-species-runtime.md new file mode 100644 index 000000000..d5202e853 --- /dev/null +++ b/docs/native-gen2-species-runtime.md @@ -0,0 +1,41 @@ +# Native GSC species identities + +An unchanged consumer can use the native Pokemon's `species` to select its +model. Numbered importer placeholders such as `SPECIES_155` previously stayed +on the runtime Pokemon, even though named registry lookups were available. +That lookup alone cannot satisfy a catalog keyed by `CYNDAQUIL`. + +The normal pre-mod `Data:seedDefaults` pass now canonicalizes a complete native +Gold/Silver/Crystal graph. It requires all 251 declared slots, matching record +IDs, native indices and dex numbers, and no authored lookup/collision. Partial +datasets, other generations and alternative cartridges retain their own IDs. +The names are stable constants rather than localized display names. + +Species records, encounter/trainer/gift/evolution references and species-keyed +runtime tables change together, in place, after an atomic collision preflight. +Existing table identity, native indices, art paths and literal text/provenance +remain intact. `FARFETCHD` and `MR_MIME` use the public punctuation convention; +their earlier spellings remain lookup aliases. Numbered addresses also remain +aliases, so registry editing, rollback, replacement and deletion still share +one stored record. A second default-seeding pass cannot revive a deleted row. + +Native script byte operands resolve through the current species order. Numeric +writeback reads the native definition's index. Ditto/Nidoran breeding, Togepi +hatching, party queries, gifts, trades and roamers use the same identity space. +Save validation folds old IDs in party, sparse boxes, day care, held parties, +Hall of Fame, roaming and Pokedex state. It preserves true dex bits, native +health/moves and literal nicknames. The ordinary save codec owns persistence; +there is no file migration or original-save write in this normalization. + +ROM-free regressions exercise the normal Data publication pass, actual Pokemon +construction for all 251 slots in three editions, the real registry, native +script compiler/executor, breeding/hatching and save encode/decode/validation. +The private unchanged-source diagnostic separately passes native Pokemon and +frozen Gen1-reference Pokemon through the supplied real model selector and +catalog: the preceding fork rejected 753/753 identities, and the corrected +graph accepts 753/753. That is selector evidence, not complete model rendering, +Windows, whole-mod or release qualification. GPU observations are recorded +separately. No mod source or protected installation is changed. + +Original contributions by Roxas2712; see `LICENSE-ROXAS2712.md`. Existing +source rights and notices remain effective. diff --git a/src/core/Data.lua b/src/core/Data.lua index e5350464e..44ae3e523 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -1897,6 +1897,8 @@ function Data:seedDefaults() end seedMissingGen2Tilesets(self) self:publishGen2Palettes() + require("src.core.Gen2SpeciesNames").canonicalize(self) + require("src.core.Gen2SpeciesNames").publish(self) ensureGen2WarpFallbacks(self) ensureGen2HomeTextFallbacks(self) end diff --git a/src/core/Gen2SpeciesNames.lua b/src/core/Gen2SpeciesNames.lua new file mode 100644 index 000000000..319c8c575 --- /dev/null +++ b/src/core/Gen2SpeciesNames.lua @@ -0,0 +1,211 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- G/S/C species identifiers used by the separate Gen1Recomp reference +-- 535a5ec0 (constant names only; no ROM bytes or artwork). Complete native +-- cartridges use named runtime identities; old SPECIES_nnn addresses remain +-- lookup aliases. Partial/authored datasets retain their existing identities. +local Version = require("src.core.GameVersion") +local M = {} +local order = [[ +BULBASAUR IVYSAUR VENUSAUR CHARMANDER CHARMELEON CHARIZARD SQUIRTLE WARTORTLE +BLASTOISE CATERPIE METAPOD BUTTERFREE WEEDLE KAKUNA BEEDRILL PIDGEY +PIDGEOTTO PIDGEOT RATTATA RATICATE SPEAROW FEAROW EKANS ARBOK +PIKACHU RAICHU SANDSHREW SANDSLASH NIDORAN_F NIDORINA NIDOQUEEN NIDORAN_M +NIDORINO NIDOKING CLEFAIRY CLEFABLE VULPIX NINETALES JIGGLYPUFF WIGGLYTUFF +ZUBAT GOLBAT ODDISH GLOOM VILEPLUME PARAS PARASECT VENONAT +VENOMOTH DIGLETT DUGTRIO MEOWTH PERSIAN PSYDUCK GOLDUCK MANKEY +PRIMEAPE GROWLITHE ARCANINE POLIWAG POLIWHIRL POLIWRATH ABRA KADABRA +ALAKAZAM MACHOP MACHOKE MACHAMP BELLSPROUT WEEPINBELL VICTREEBEL TENTACOOL +TENTACRUEL GEODUDE GRAVELER GOLEM PONYTA RAPIDASH SLOWPOKE SLOWBRO +MAGNEMITE MAGNETON FARFETCH_D DODUO DODRIO SEEL DEWGONG GRIMER +MUK SHELLDER CLOYSTER GASTLY HAUNTER GENGAR ONIX DROWZEE +HYPNO KRABBY KINGLER VOLTORB ELECTRODE EXEGGCUTE EXEGGUTOR CUBONE +MAROWAK HITMONLEE HITMONCHAN LICKITUNG KOFFING WEEZING RHYHORN RHYDON +CHANSEY TANGELA KANGASKHAN HORSEA SEADRA GOLDEEN SEAKING STARYU +STARMIE MR__MIME SCYTHER JYNX ELECTABUZZ MAGMAR PINSIR TAUROS +MAGIKARP GYARADOS LAPRAS DITTO EEVEE VAPOREON JOLTEON FLAREON +PORYGON OMANYTE OMASTAR KABUTO KABUTOPS AERODACTYL SNORLAX ARTICUNO +ZAPDOS MOLTRES DRATINI DRAGONAIR DRAGONITE MEWTWO MEW CHIKORITA +BAYLEEF MEGANIUM CYNDAQUIL QUILAVA TYPHLOSION TOTODILE CROCONAW FERALIGATR +SENTRET FURRET HOOTHOOT NOCTOWL LEDYBA LEDIAN SPINARAK ARIADOS +CROBAT CHINCHOU LANTURN PICHU CLEFFA IGGLYBUFF TOGEPI TOGETIC +NATU XATU MAREEP FLAAFFY AMPHAROS BELLOSSOM MARILL AZUMARILL +SUDOWOODO POLITOED HOPPIP SKIPLOOM JUMPLUFF AIPOM SUNKERN SUNFLORA +YANMA WOOPER QUAGSIRE ESPEON UMBREON MURKROW SLOWKING MISDREAVUS +UNOWN WOBBUFFET GIRAFARIG PINECO FORRETRESS DUNSPARCE GLIGAR STEELIX +SNUBBULL GRANBULL QWILFISH SCIZOR SHUCKLE HERACROSS SNEASEL TEDDIURSA +URSARING SLUGMA MAGCARGO SWINUB PILOSWINE CORSOLA REMORAID OCTILLERY +DELIBIRD MANTINE SKARMORY HOUNDOUR HOUNDOOM KINGDRA PHANPY DONPHAN +PORYGON2 STANTLER SMEARGLE TYROGUE HITMONTOP SMOOCHUM ELEKID MAGBY +MILTANK BLISSEY RAIKOU ENTEI SUICUNE LARVITAR PUPITAR TYRANITAR +LUGIA HO_OH CELEBI +]] +local nativeByName = {} +local nameByNumber = {} +local count = 0 +for name in order:gmatch("%S+") do + count = count + 1 + nativeByName[name] = string.format("SPECIES_%03d", count) + nameByNumber[count] = ({FARFETCH_D='FARFETCHD',MR__MIME='MR_MIME'})[name] or name +end +local states = setmetatable({}, {__mode="k"}) +local function supported() + local v = Version.get() + return v == "gold" or v == "silver" or v == "crystal" +end + +-- The original importer scaffold used numbered placeholders. Normal game +-- loads now publish one authoritative, named GSC graph before mods or game +-- objects are created. Old addresses remain lookup aliases, not extra rows. +-- A total conversion, partial fixture, authored lookup or identity collision +-- is not enough evidence to reinterpret its species numbering. +function M.canonicalize(data) + local pokemon=type(data)=='table'and data.pokemon + local prior=type(pokemon)=='table'and states[pokemon] + if prior and prior.canonical then return getmetatable(pokemon)==prior.meta end + if not supported()or type(pokemon)~='table'or getmetatable(pokemon)~=nil then return false end + local constants=data.constants + local species=constants and constants.speciesOrder + if type(species)~='table'or #species~=251 then return false end + local aliases={} + for i=1,251 do + local id=string.format('SPECIES_%03d',i) + local def=rawget(pokemon,id) + local name=nameByNumber[i] + if species[i]~=id or type(def)~='table'or def.id~=id or def.index~=i or def.dex~=i + or rawget(pokemon,name)~=nil then return false end + aliases[id]=name + end + aliases.FARFETCH_D,aliases.MR__MIME='FARFETCHD','MR_MIME' + -- Plan the complete graph transaction before changing any table. Exact + -- identity tokens change; prose, filesystem paths and provenance do not. + local literal={text=true,text_pointers=true,name=true,nickname=true,title=true,body=true, + message=true,description=true,label=true,prompt=true,caption=true,source=true, + sourceId=true,native=true,path=true,file=true,filename=true} + local seen,plan={},{} + local function visit(row) + if type(row)~='table'or seen[row]then return true end + seen[row]=true + local targets={} + for key,value in pairs(row)do + if not literal[key]then + local target=aliases[key]or key + local replacement=type(value)=='string'and(aliases[value]or value)or value + if target~=key then + local existing=rawget(row,target) + if existing~=nil and existing~=replacement then return false end + end + if targets[target]~=nil and targets[target]~=replacement then return false end + targets[target]=replacement + if target~=key or replacement~=value then + plan[#plan+1]={row=row,key=key,target=target,value=replacement} + end + if not visit(value)then return false end + end + end + return true + end + if not visit(data)then return false end + for _,op in ipairs(plan)do + if op.key~=op.target then op.row[op.key]=nil end + op.row[op.target]=op.value + end + local meta={__index=function(t,id) + if supported()then local name=aliases[id];if name then return rawget(t,name)end end + end} + states[pokemon]={aliases=aliases,meta=meta,canonical=true} + setmetatable(pokemon,meta) + constants.speciesIdentity='gsc-canonical-v1' + return true +end + +function M.fromNumber(data,index) + index=math.floor(tonumber(index)or 0) + local species=data and data.constants and data.constants.speciesOrder + return species and species[index]or string.format('SPECIES_%03d',index) +end + +function M.number(data,species) + if type(species)=='table'then species=species.species end + local def=data and data.pokemon and data.pokemon[species] + return tonumber(def and def.index)or tonumber(species) + or(type(species)=='string'and tonumber(species:match('^SPECIES_(%d+)$')))or 0 +end + +function M.same(data,a,b) + local pokemon=data and data.pokemon + return M.registryId(pokemon,a)==M.registryId(pokemon,b) +end + +-- Save validation already owns the native Pokemon lists. Normalize their +-- identity when it scrubs them, and fold legacy Pokedex keys without losing +-- a previously true seen/owned bit. No save file is written here. +function M.normalizeDex(data,dex) + local state=data and data.pokemon and states[data.pokemon] + if not(supported()and state and state.canonical and type(dex)=='table')then return end + for _,kind in ipairs{'seen','owned'}do + local flags=dex[kind] + if type(flags)=='table'then + local changes={} + for id,value in pairs(flags)do + local target=state.aliases[id] + if target then changes[#changes+1]={id,target,value}end + end + for _,entry in ipairs(changes)do + flags[entry[2]]=flags[entry[2]]or entry[3] + flags[entry[1]]=nil + end + end + end +end + +function M.publish(data) + local pokemon = type(data) == "table" and data.pokemon + if not supported() or type(pokemon) ~= "table" then return false end + local previous = states[pokemon] + if previous then return getmetatable(pokemon) == previous.meta end + -- Authored dataset lookup behavior is never replaced or wrapped. + if getmetatable(pokemon) ~= nil then return false end + local nativeOrder = data.constants and data.constants.speciesOrder + if type(nativeOrder) ~= "table" then return false end + local aliases = {} + for name, id in pairs(nativeByName) do + local def = rawget(pokemon, id) + local index = tonumber(id:match("(%d+)$")) + if nativeOrder[index] == id and type(def) == "table" and def.id == id + and def.dex == index and def.index == index + and rawget(pokemon, name) == nil then + aliases[name] = id + end + end + if not next(aliases) then return false end + local meta = {__index=function(t, name) + if supported() then + local id = aliases[name] + if id then return rawget(t, id) end + end + end} + states[pokemon] = {aliases=aliases, meta=meta} + setmetatable(pokemon, meta) + return true +end + +-- The real Registry uses the same address for reads, edits, deletion, +-- conflicts and rollback. Explicit authored names always retain precedence. +function M.registryId(pokemon, id) + if not supported() or type(pokemon) ~= "table" or rawget(pokemon, id) ~= nil then return id end + local state = states[pokemon] + if not state or getmetatable(pokemon) ~= state.meta then return id end + return state.aliases[id] or id +end + +function M.write(target, registry) + local state = supported() and states[target] + for id in pairs(registry.ops) do + local value = registry:get(id) + -- Full replacements addressed by a legacy name still carry a native id. + if state and getmetatable(target) == state.meta and type(value) == "table" + and state.aliases[value.id] == id then value.id = id end + target[id] = value + end +end +return M diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2bfe53530..39c6bec88 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1978,6 +1978,7 @@ end -- indexes; the level floor widens to 1 because a freshly caught level-1 -- mon can legitimately sit in a save local function scrubKnownMon(mon, data) + mon.species=require('src.core.Gen2SpeciesNames').registryId(data.pokemon,mon.species) if type(mon.dvs) == "table" then for stat, v in pairs(mon.dvs) do mon.dvs[stat] = clamp(v, 0, 15, 0) end end @@ -2111,6 +2112,13 @@ function SaveData.validate(save, data) for b, box in ipairs(save.boxes or {}) do scrubMonList(box, "box " .. b, save, data, report, Boxes.capacity()) end + if GameVersion.isGen2() then + scrubMonList(save.g2SecondMonBackup,"held party",save,data,report) + local contest=type(save.g2BugContest)=='table'and save.g2BugContest + if contest then + scrubMonList(contest.party,"contest held party",save,data,report) + end + end local daycare = save.daycare if type(daycare) == "table" and type(daycare.mon) == "table" then if not known(data.pokemon, daycare.mon.species) then @@ -2161,6 +2169,7 @@ function SaveData.validate(save, data) end scrubMaps(save, data, report) local dex = save.pokedex + require("src.core.Gen2SpeciesNames").normalizeDex(data,dex) if type(dex) == "table" then for _, key in ipairs({ "seen", "owned" }) do if type(dex[key]) == "table" then @@ -2180,13 +2189,27 @@ function SaveData.validate(save, data) if type(entry) == "table" then for i = 1, #entry do local mon = entry[i] - if type(mon) == "table" and mon.species ~= nil - and not known(data.pokemon, mon.species) then - mon.species = nil + if type(mon) == "table" and mon.species ~= nil then + if known(data.pokemon, mon.species) then + mon.species = require('src.core.Gen2SpeciesNames').registryId(data.pokemon,mon.species) + else + mon.species = nil + end end end end end + if GameVersion.isGen2() then + local Species=require('src.core.Gen2SpeciesNames') + if save.g2PokeonlySpecies~=nil then + save.g2PokeonlySpecies=Species.registryId(data.pokemon,save.g2PokeonlySpecies) + end + for _,roamer in pairs(type(save.g2Roam)=='table'and save.g2Roam or {})do + if type(roamer)=='table'and roamer.speciesId~=nil then + roamer.speciesId=Species.registryId(data.pokemon,roamer.speciesId) + end + end + end -- BERRY TREES A SAVE PREDATING THEM NEVER GOT. -- -- Reported from play, after the plots were made to draw what is growing in diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 5aa2d364e..9fa7b13f6 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1310,6 +1310,7 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + require("src.core.Gen2SpeciesNames").publish(data) local palette=package.loaded['src.render.PaletteFX'] if palette and palette.clearGen2Context then palette.clearGen2Context()end local clock = package.loaded["src.core.Gen2Clock"] diff --git a/src/mods/Registry.lua b/src/mods/Registry.lua index 9719673ef..c04a81b2c 100644 --- a/src/mods/Registry.lua +++ b/src/mods/Registry.lua @@ -28,11 +28,17 @@ function Registry.new(name, spec) }, Registry) end +local function canonicalId(self,id) + if self.spec.canonicalId then return self.spec.canonicalId(self.base and self.base(),id)end + return id +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") + id=canonicalId(self,id) local list = self.ops[id] if not list then list = {} @@ -134,6 +140,7 @@ function Registry:remove(id, owner) end function Registry:get(id) + id=canonicalId(self,id) if self.spec.semantics == "compose" then -- chain() sorts top priority first, so the head is the effective value local chain = self:chain(id) @@ -161,6 +168,7 @@ end -- Owner-less ops are left out. They are the engine's own contributions and -- nobody is choosing between them and a mod. function Registry:claimants(id) + id=canonicalId(self,id) local seen, out = {}, {} for _, entry in ipairs(self.ops[id] or {}) do local owner = entry.owner @@ -197,6 +205,7 @@ end -- 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") + id=canonicalId(self,id) self.prefer = self.prefer or {} self.prefer[id] = owner self.cache[id] = nil @@ -204,6 +213,7 @@ function Registry:preferOwner(id, owner) end function Registry:preferredOwner(id) + id=canonicalId(self,id) return self.prefer and self.prefer[id] or nil end @@ -243,6 +253,7 @@ end -- compose only: the ordered value list for an id function Registry:chain(id) + id=canonicalId(self,id) assert(self.spec.semantics == "compose", self.name .. ": chain is compose-only") local entries = composed(self, id) @@ -255,6 +266,7 @@ end -- attribute dispatch (map_scripts runner sources) read both sides of the -- same fold function Registry:chainOwners(id) + id=canonicalId(self,id) assert(self.spec.semantics == "compose", self.name .. ": chainOwners is compose-only") local entries = composed(self, id) @@ -267,6 +279,7 @@ end -- consumer that keeps its own base contribution outside the registry -- (MapScripts' engine scripts) knows the total conversion excluded it function Registry:chainReplacesBase(id) + id=canonicalId(self,id) assert(self.spec.semantics == "compose", self.name .. ": chainReplacesBase is compose-only") local _, replacesBase = composed(self, id) diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..30e585934 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -408,6 +408,8 @@ local R = {} Schemas.REGISTRIES = R R.pokemon = { + canonicalId=require("src.core.Gen2SpeciesNames").registryId, + write=require("src.core.Gen2SpeciesNames").write, semantics = "record", target = "pokemon", fields = { id = f.str, name = f.str, dex = f.int(1), diff --git a/src/pokemon/DayCare.lua b/src/pokemon/DayCare.lua index e5a1c46c6..9b54b19d7 100644 --- a/src/pokemon/DayCare.lua +++ b/src/pokemon/DayCare.lua @@ -226,6 +226,7 @@ end -- --------------------------------------------------------------------------- local DITTO = "SPECIES_132" +local Species=require('src.core.Gen2SpeciesNames') -- GetGender (03:$50C6). The base_stats byte is the threshold the attack DV -- is compared against, scaled by 16: below it the mon is female. @@ -279,7 +280,7 @@ function DayCare.pair(data, save) local a, b = DayCare.mon(save, DayCare.MAN), DayCare.mon(save, DayCare.LADY) if not (a and b) then return nil end if a.isEgg or b.isEgg then return nil end - local ditto = (a.species == DITTO and 1 or 0) + (b.species == DITTO and 1 or 0) + local ditto = (Species.same(data,a.species,DITTO) and 1 or 0) + (Species.same(data,b.species,DITTO) and 1 or 0) if ditto == 2 then return nil end -- .ditto_and_ditto local ga, gb = eggGroupsOf(data, a), eggGroupsOf(data, b) if not (breeds(ga) and breeds(gb)) then return nil end @@ -404,18 +405,18 @@ function DayCare.eggSpecies(data, save) local a, b = DayCare.pair(data, save) if not a then return nil end local mother = b - if b.species == DITTO then + if Species.same(data,b.species,DITTO) then mother = a - elseif a.species ~= DITTO then + elseif not Species.same(data,a.species,DITTO) then if DayCare.gender(data, a) == "female" then mother = a end end local species = baseForm(data, mother.species) if require("src.core.GameVersion").get()=="emerald" then species=require("src.pokemon.Gen3Breeding").species(species,a,b) end - if species == "SPECIES_029" and require("src.pokemon.Gen2Breeding").isVanilla() then + if Species.same(data,species,'SPECIES_029')and require("src.pokemon.Gen2Breeding").isVanilla() then -- InitBreeding: random byte < 128 gives Nidoran F, otherwise Nidoran M. - return math.random(0,255) < 128 and species or "SPECIES_032" + return math.random(0,255) < 128 and species or Species.fromNumber(data,32) end return species end @@ -453,8 +454,8 @@ function DayCare.parents(data, save) if DayCare.gender(data, other) == "female" then return other, ditto end return ditto, other end - if a.species == DITTO then return withDitto(b, a) end - if b.species == DITTO then return withDitto(a, b) end + if Species.same(data,a.species,DITTO) then return withDitto(b, a) end + if Species.same(data,b.species,DITTO) then return withDitto(a, b) end if DayCare.gender(data, a) == "female" then return a, b end if DayCare.gender(data, b) == "female" then return b, a end -- no gender data at all (a Gen 1 import): the LADY's slot stands in as the diff --git a/src/pokemon/Gen2Breeding.lua b/src/pokemon/Gen2Breeding.lua index 1990bff8e..233ac8b4e 100644 --- a/src/pokemon/Gen2Breeding.lua +++ b/src/pokemon/Gen2Breeding.lua @@ -41,7 +41,7 @@ function Breeding.hatch(game, mon) game.save.pokedex=dex dex.seen,dex.owned=dex.seen or {},dex.owned or {} dex.seen[mon.species],dex.owned[mon.species]=true,true - if mon.species=="SPECIES_175" then + if require('src.core.Gen2SpeciesNames').same(game.data,mon.species,'SPECIES_175')then game.save.flags=game.save.flags or {} game.save.flags[require("src.script.Gen2Flags").eventFlag(0x54)]=true end diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 17c5685c0..3e97dd6e4 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -174,6 +174,7 @@ function Pokemon.applySeed(data, mon, seed) end function Pokemon.new(data, species, level, rng, form) + species=require("src.core.Gen2SpeciesNames").registryId(data.pokemon,species) local nativeForm=(data.constants or {}).gen==4 and (form or 0) or nil local speciesDef=(data.pokemon or {})[species] if require("src.core.GameVersion").get()=="polishedcrystal" and speciesDef and speciesDef.forms then diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index 4de6372fc..8b26df5c9 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -22,6 +22,7 @@ local Logger = require("src.core.Logger") local Strings = require("src.core.Strings") local Gen2Commands = {} +local Species=require("src.core.Gen2SpeciesNames") local function scriptVar(ctx) return ctx.g2Var or 0 @@ -398,7 +399,7 @@ local CUSTOM_ARG_DISPATCH = { function Commands.g2_give_poke(ctx,species,level,item) species=tonumber(species) or 0 if species==0 then species=scriptVar(ctx) end - local id=("SPECIES_%03d"):format(species) + local id=Species.fromNumber(ctx.game.data,species) if not ctx.game.data.pokemon[id] then setScriptVar(ctx,2);return end if #(ctx.save.party or {})>=6 then local Boxes=require('src.pokemon.Boxes') @@ -1815,7 +1816,7 @@ function Commands.g2_cry(ctx, species) -- a species of nil is Prism's GetScriptByteOrVar zero: "whatever the script -- variable holds", which is how the six Prism starter balls share one script if not species then - species = string.format("SPECIES_%03d", math.floor(scriptVar(ctx)) % 256) + species = Species.fromNumber(ctx.game.data,math.floor(scriptVar(ctx))%256) end require("src.core.Sound").playCry(ctx.game.data, species) end @@ -1834,7 +1835,7 @@ function Commands.g2_pokepic(ctx, species) if not (ow and ctx.game) then return end -- `pokepic 0` reads wScriptVar instead of a literal if species == nil and type(ctx.g2Var) == "number" and ctx.g2Var > 0 then - species = string.format("SPECIES_%03d", ctx.g2Var) + species = Species.fromNumber(ctx.game.data,ctx.g2Var) end if not species then return end local path, trueColor = @@ -1985,7 +1986,7 @@ end function Commands.g2_check_poke(ctx, species) for _, mon in ipairs(ctx.save.party or {}) do - if mon.species == species then + if Species.same(ctx.game.data,mon.species,species)then ctx.lastCheck = true ctx.g2Var = 1 return @@ -2382,13 +2383,13 @@ end -- no handler it always answered no, so walking into the lab with the hatched -- TOGEPI got Elm's old "how is the EGG doing" line for the rest of the game. function Commands.g2_find_party_species_own(ctx) - local want = string.format("SPECIES_%03d", math.floor(scriptVar(ctx)) % 256) + local want = Species.fromNumber(ctx.game.data,math.floor(scriptVar(ctx))%256) local player = ctx.save.player or {} local found = false for _, mon in ipairs(ctx.save.party or {}) do -- an EGG does not count: the ROM reads the species out of the party slot -- and an egg's slot species is the hatchling's, so the check has to skip it - if mon.species == want and not mon.isEgg then + if Species.same(ctx.game.data,mon.species,want)and not mon.isEgg then local ownName = mon.ot == nil or mon.ot == player.name local ownId = mon.otId == nil or mon.otId == player.id if ownName and ownId then found = true break end @@ -3099,7 +3100,7 @@ function Commands.g2_return_nobus_aggron(ctx) runner:yield() local function result(n) ctx.g2Var, ctx.lastCheck = n, n ~= 0 end if not picked then return result(1) end - if picked.species ~= "SPECIES_045" or picked.otId ~= NOBU_OT_ID + if not Species.same(ctx.game.data,picked.species,"SPECIES_045")or picked.otId ~= NOBU_OT_ID or picked.ot ~= NOBU_OT then return result(0) end @@ -3143,7 +3144,7 @@ function Commands.g2_select_mon_from_party(ctx) ctx.g2Var, ctx.lastCheck = 0xFF, true return end - local index = tonumber(tostring(picked.species or ""):match("(%d+)$")) or 0 + local index=Species.number(ctx.game.data,picked) ctx.g2Var, ctx.lastCheck = index, index ~= 0 -- the name the following text splices in ctx.g2MonName = picked.nickname or picked.species @@ -3484,7 +3485,7 @@ function Commands.g2_seen_mon(ctx) local n = ctx.g2Var or 0 if n < 1 then return end local dex = ctx.save.pokedex - if dex and dex.seen then dex.seen[string.format("SPECIES_%03d", n)] = true end + if dex and dex.seen then dex.seen[Species.fromNumber(ctx.game.data,n)] = true end end -- `GetFirstPokemonHappiness` (engine/happiness.asm): walks past EGGs to the @@ -3638,7 +3639,7 @@ function Commands.g2_trade(ctx, index) if not picked then return say("declined", Strings("You don't want to\ntrade? Aww…")) end - if picked.species ~= trade.give then + if not Species.same(ctx.game.data,picked.species,trade.give)then return say("wrongMon", Strings("Huh? That's not\n%s.", giveName)) end -- CheckTradeGender (3F:$4C23): only DORIS the DODRIO asks, and she asks for @@ -3743,7 +3744,7 @@ function Commands.g2_return_shuckie(ctx) ctx.g2Var, ctx.lastCheck = n, n ~= 0 end if not picked then return result(1) end - if picked.species ~= "SPECIES_213" or picked.otId ~= SHUCKIE_OT_ID + if not Species.same(ctx.game.data,picked.species,"SPECIES_213")or picked.otId ~= SHUCKIE_OT_ID or picked.ot ~= SHUCKIE_OT then return result(0) end @@ -4640,11 +4641,8 @@ local function pickPartyMon(ctx) return picked end -local function speciesNumber(mon) - if not mon then return 0 end - if type(mon.species) == "number" then return mon.species end - local n = tostring(mon.species or ""):match("(%d+)$") - return tonumber(n) or 0 +local function speciesNumber(ctx,mon) + return mon and Species.number(ctx.game.data,mon)or 0 end -- `special BillsGrandfather` ($4C). Party menu; wScriptVar = species id. @@ -4655,7 +4653,7 @@ function Commands.g2_bills_grandfather(ctx) ctx.lastCheck = false return end - ctx.g2Var = speciesNumber(picked) + ctx.g2Var = speciesNumber(ctx,picked) ctx.lastCheck = ctx.g2Var ~= 0 end @@ -5059,7 +5057,7 @@ function Commands.g2_init_roam_mons(ctx) if roamsInThisVersion(name) then ctx.save.g2Roam[name] = { mapId = startMap, - speciesId = ROAM_SPECIES_ID[name], + speciesId=Species.registryId(ctx.game.data.pokemon,ROAM_SPECIES_ID[name]), landmark = mapToLandmark(ctx.game, startMap) or LANDMARK_BY_MAP[startMap], active = true, -- InitRoamMons: `ld a, 40 / ld [wRoamMon1Level], a` for every beast, @@ -5149,7 +5147,7 @@ function Commands.g2_ensure_roam_landmarks(ctx) if not roamsInThisVersion(name) then roam[name] = nil elseif type(info) == "table" and info.mapId then - info.speciesId = info.speciesId or ROAM_SPECIES_ID[name] + info.speciesId=Species.registryId(ctx.game.data.pokemon,info.speciesId or ROAM_SPECIES_ID[name]) -- Saves released before the beasts could actually be MET have a slot -- with no level and no HP in it. Fill those in rather than leaving a -- beast that cannot be turned into an encounter. @@ -5424,7 +5422,7 @@ function Commands.g2_rock_encounter(ctx) pick = pick - row.chance if pick < 0 then ctx.g2Wild = {species = row.species, level = row.level} - species = tonumber(row.species:match("(%d+)$")) or 0 + species=Species.number(ctx.game.data,row.species) break end end @@ -5823,7 +5821,7 @@ end -- extractor can see a zero on: `pokenamemem 0, 0` and `checkitem 0` function Commands.g2_getmonname_var(ctx, buffer) Commands.g2_getmonname(ctx, - string.format("SPECIES_%03d", math.floor(scriptVar(ctx)) % 256), buffer) + Species.fromNumber(ctx.game.data,math.floor(scriptVar(ctx))%256), buffer) end function Commands.g2_check_item_var(ctx) diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 0d5c51cfe..3f6a7a3b0 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -574,7 +574,7 @@ L.getmonname = function(ir, s) if (ir[2] or 0) == 0 then emit(s, { "g2_getmonname_var", ir[3] }) else - emit(s, { "g2_getmonname", string.format("SPECIES_%03d", ir[2]), ir[3] }) + emit(s, { "g2_getmonname", require('src.core.Gen2SpeciesNames').fromNumber(s.data,ir[2]), ir[3] }) end end L.takeitem = function(ir, s) emit(s, { "take_item", itemId(ir[2]), ir[3] }) end @@ -604,13 +604,13 @@ L.checkmoney = function(ir, s) emit(s, { "g2_check_money", ir[3] }) end -- verbosegiveitem already handled, so only the bag-full test carries weight L.pocketisfull = function(_, s) emit(s, { "g2_pocket_full" }) end L.checkpoke = function(ir, s) - emit(s, { "g2_check_poke", string.format("SPECIES_%03d", ir[2] or 0) }) + emit(s, { "g2_check_poke", require('src.core.Gen2SpeciesNames').fromNumber(s.data,ir[2]or 0) }) end -- `giveegg species, level`: an EGG party member. Elm's aide hands one over -- outside the Violet gym and the script `iftrue`s on the carry, so a missing -- lowering left the whole delivery scene dead. L.giveegg = function(ir, s) - emit(s, { "g2_give_egg", string.format("SPECIES_%03d", ir[2] or 0), ir[3] }) + emit(s, { "g2_give_egg", require('src.core.Gen2SpeciesNames').fromNumber(s.data,ir[2]or 0), ir[3] }) end L.givepoke = function(ir, s) emit(s, { "g2_give_poke", ir[2], ir[3], ir[4] }) @@ -625,7 +625,7 @@ L.cry = function(ir, s) -- zero operand, which is how one starter script cries six different mons. -- Lowered literally it asked for SPECIES_000 and nothing sounded. emit(s, { "g2_cry", - (ir[2] or 0) ~= 0 and string.format("SPECIES_%03d", ir[2]) or nil }) + (ir[2] or 0) ~= 0 and require('src.core.Gen2SpeciesNames').fromNumber(s.data,ir[2]) or nil }) end -- `pokepic ` opens the framed front pic and keeps running; @@ -636,7 +636,7 @@ L.pokepic = function(ir, s) local species = ir[2] emit(s, { "g2_pokepic", (species and species > 0) - and string.format("SPECIES_%03d", species) or nil }) + and require('src.core.Gen2SpeciesNames').fromNumber(s.data,species) or nil }) end L.closepokepic = function(_, s) emit(s, { "g2_close_pokepic" }) end @@ -693,7 +693,7 @@ L.loadtrainer = function(ir, s) emit(s, { "g2_load_trainer", ir[2], ir[3] }) end -- Sharing loadtrainer's lowering meant Sudowoodo and the Red Gyarados asked -- for trainer class 185, found nothing, and silently skipped the battle. L.loadwildmon = function(ir, s) - emit(s, { "g2_load_wild", string.format("SPECIES_%03d", ir[2] or 0), ir[3] }) + emit(s, { "g2_load_wild", require('src.core.Gen2SpeciesNames').fromNumber(s.data,ir[2]or 0), ir[3] }) end L.winlosstext = function(ir, s) emit(s, { "g2_winloss", ir[2], ir[3] }) end L.startbattle = function(_, s) emit(s, { "g2_start_battle" }) end @@ -1796,7 +1796,7 @@ end L.showcrytext = function(ir, s) local species = ir[3] emit(s, { "g2_cry", (species and species > 0) - and string.format("SPECIES_%03d", species) or nil }) + and require('src.core.Gen2SpeciesNames').fromNumber(s.data,species) or nil }) s.lastText = ir[2] showText(s, ir[2]) s.lastTextRow = #s.out @@ -2378,6 +2378,7 @@ function Gen2ScriptVM.compile(data, entry) local out, queued, order = {}, { [entry] = true }, { entry } local counter = 0 local state = { + data = data, out = out, stds = pool.stds, has = function(label) diff --git a/src/script/Gen2Specials.lua b/src/script/Gen2Specials.lua index b554b340e..5516c3fd3 100644 --- a/src/script/Gen2Specials.lua +++ b/src/script/Gen2Specials.lua @@ -39,14 +39,13 @@ local function answer(ctx, value) -- restarts the script), so even `return answer(...)` must return nil. end -local function speciesKey(n) - return string.format("SPECIES_%03d", math.floor(n or 0) % 256) +local function speciesKey(ctx,n) + return require('src.core.Gen2SpeciesNames').fromNumber(ctx.game.data,math.floor(n or 0)%256) end -local function speciesNumber(mon) +local function speciesNumber(ctx,mon) if not mon then return 0 end - if type(mon.species) == "number" then return mon.species end - return tonumber(tostring(mon.species or ""):match("(%d+)$")) or 0 + return require('src.core.Gen2SpeciesNames').number(ctx.game.data,mon) end local function speciesName(ctx, species) @@ -155,7 +154,7 @@ local function ownsAnywhere(ctx, key) local Party = require("src.pokemon.Party") local player = save.player or {} local function match(mon) - if not mon or mon.species ~= key then return false end + if not mon or not require('src.core.Gen2SpeciesNames').same(ctx.game.data,mon.species,key)then return false end if Party.isEgg(mon) then return false end local ownName = mon.ot == nil or mon.ot == player.name local ownId = mon.otId == nil or mon.otId == player.id @@ -210,7 +209,7 @@ end -- in wScriptVar, anywhere, raised themselves? Seven call sites, all of them -- NPCs who react to a particular mon. function Commands.g2_mon_check(ctx) - answer(ctx, ownsAnywhere(ctx, speciesKey(ctx.g2Var)) and 1 or 0) + answer(ctx, ownsAnywhere(ctx, speciesKey(ctx,ctx.g2Var)) and 1 or 0) end -- BeastsCheck: Raikou, Entei AND Suicune, all three, same ownership rule. @@ -307,9 +306,10 @@ end -- already has that as g2_find_party_species_own. function Commands.g2_find_party_species(ctx) local Party = require("src.pokemon.Party") - local want = speciesKey(ctx.g2Var) + local want = speciesKey(ctx,ctx.g2Var) for _, mon in ipairs(ctx.save.party or {}) do - if mon.species == want and not Party.isEgg(mon) then return answer(ctx, 1) end + if require('src.core.Gen2SpeciesNames').same(ctx.game.data,mon.species,want) + and not Party.isEgg(mon)then return answer(ctx, 1)end end answer(ctx, 0) end @@ -341,7 +341,7 @@ end -- the mon is already in the party, so the dex marks are the point. -- Fifteen call sites -- every prize in both game corners. function Commands.g2_prize_mon_dex(ctx) - local key = speciesKey(ctx.g2Var) + local key = speciesKey(ctx,ctx.g2Var) local dex = ctx.save.pokedex if not dex then return end dex.seen, dex.owned = dex.seen or {}, dex.owned or {} @@ -437,7 +437,7 @@ function Commands.g2_give_dratini(ctx) local moves = ctx.game.data.moves or {} for index = #(ctx.save.party or {}), 1, -1 do local mon = ctx.save.party[index] - if mon and speciesNumber(mon) == 147 then -- DRATINI + if mon and speciesNumber(ctx,mon) == 147 then -- DRATINI mon.moves = {} for _, id in ipairs(set) do local def = moves[id] @@ -525,7 +525,7 @@ end function Commands.g2_magikarp_length(ctx) local picked = pickMon(ctx) if not picked then return answer(ctx, 1) end - if speciesNumber(picked) ~= 129 then return answer(ctx, 0) end + if speciesNumber(ctx,picked) ~= 129 then return answer(ctx, 0) end local feet, inches = magikarpLength(picked, picked.otId or (ctx.save.player and ctx.save.player.id) or 0) local shown = lengthText(feet, inches) @@ -870,7 +870,7 @@ function Commands.g2_happiness_tutor(ctx) if (picked.happiness or 0) < 200 then return say(ctx, nil, Strings("%s doesn't\nlike you enough\nyet.", monName(ctx, picked))) end - local name = HAPPINESS_MOVES[speciesNumber(picked)] + local name = HAPPINESS_MOVES[speciesNumber(ctx,picked)] local id = name and moveKey(ctx.game.data, name) if not id then return say(ctx, nil, Strings("There's nothing I\ncan teach it.")) diff --git a/src/world/NPC.lua b/src/world/NPC.lua index 28b1a2b3e..1550de56a 100644 --- a/src/world/NPC.lua +++ b/src/world/NPC.lua @@ -231,7 +231,7 @@ local function resolveBreedSprite(sprites, spriteId) local save = require("src.core.Game").save local mon = require("src.pokemon.DayCare").mon(save, tonumber(slot)) or (save and save.daycare and save.daycare.mon) - local species = mon and tonumber(tostring(mon.species):match("(%d+)$")) + local species = mon and require('src.core.Gen2SpeciesNames').number(require('src.core.Game').data,mon) return (species and sprites[string.format("SPRITE_MON_%03d", species)]) or spriteByIndex(sprites, 1) end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 891fb63d4..be73d56ab 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -13137,7 +13137,7 @@ function OverworldState:hatchEgg(hatched) -- ElmPhoneCalleeScript tests (`checkevent $2D / iffalse .next / checkevent -- $54 / iftrue .egghatched`), so without it ringing Elm after the EGG -- hatched could never reach ElmPhoneEggHatchedText. - if GameVersion.isGen2() and hatched.species == "SPECIES_175" and Game.save.flags then + if GameVersion.isGen2() and require('src.core.Gen2SpeciesNames').same(Game.data,hatched.species,'SPECIES_175') and Game.save.flags then Game.save.flags[require("src.script.Gen2Flags").eventFlag(0x54)] = true end local name = def and def.name or hatched.species diff --git a/tests/gsc_species_fixture.lua b/tests/gsc_species_fixture.lua new file mode 100644 index 000000000..ed54fdd42 --- /dev/null +++ b/tests/gsc_species_fixture.lua @@ -0,0 +1,19 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Synthetic full native identity table; contains no cartridge data/artwork. +local F={} +function F.new() + local data=require('tests.modkit').fixtures.fresh() + local copy=require('src.mods.Merge').deepCopy + local prototype=data.pokemon.FIXMON_A + data.pokemon={};data.constants.speciesOrder={} + for index=1,251 do + local id=string.format('SPECIES_%03d',index) + local def=copy(prototype) + def.id,def.index,def.dex,def.name=id,index,index,'Localized display name' + def.source='ROM:'..id + def.evolutions={};def.eggGroups={'FIELD'};def.genderRatio=127 + data.pokemon[id]=def;data.constants.speciesOrder[index]=id + end + return data +end +return F From c64b48075fd9adb5c646d75c7c14b48ff2a4df9f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:51:44 +0100 Subject: [PATCH 119/187] Include upstream GSC runtime identity regression programs --- tests/engine/gen2_species_gameplay.lua | 75 +++++++++++++++ tests/engine/gen2_species_runtime.lua | 123 +++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 tests/engine/gen2_species_gameplay.lua create mode 100644 tests/engine/gen2_species_runtime.lua diff --git a/tests/engine/gen2_species_gameplay.lua b/tests/engine/gen2_species_gameplay.lua new file mode 100644 index 000000000..1a32b90cf --- /dev/null +++ b/tests/engine/gen2_species_gameplay.lua @@ -0,0 +1,75 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local old=V.get() +local F=require('tests.gsc_species_fixture') +local Names=require('src.core.Gen2SpeciesNames') +local Pokemon=require('src.pokemon.Pokemon') +local Commands=require('src.script.Commands') +local VM=require('src.script.Gen2ScriptVM') +local DC=require('src.pokemon.DayCare') +for _,edition in ipairs{'gold','silver','crystal'}do + V.set(edition);local d=F.new();d:seedDefaults() + local function mon(index)return Pokemon.new(d,Names.fromNumber(d,index),5,function(lo)return lo end)end + d.map_scripts={scripts={IDENTITY={ + {'getmonname',155,0},{'checkpoke',155},{'giveegg',175,5},{'cry',155}, + {'pokepic',155},{'loadwildmon',133,5},{'end'}, + },GIFT={{'checkpoke',155},{'giveegg',175,5},{'end'}}}} + local rows=assert(VM.compile(d,'IDENTITY'));local lowered={} + for _,row in ipairs(rows)do lowered[row[1]]=row end + for _,op in ipairs{'g2_getmonname','g2_check_poke','g2_cry','g2_pokepic'}do + T.eq(lowered[op][2],'CYNDAQUIL',op..' resolves native byte operand through the same dataset') + end + T.eq(lowered.g2_give_egg[2],'TOGEPI');T.eq(lowered.g2_load_wild[2],'EEVEE') + local save={party={mon(155)},boxes={},flags={},player={name='TEST',id=42}, + pokedex={seen={},owned={}}} + local game={data=d,save=save,overworld={}} + local ctx={game=game,save=save,overworld=game.overworld,g2Var=155} + Commands.g2_check_poke(ctx,'SPECIES_155');T.eq(ctx.g2Var,1) + ctx.g2Var=155;Commands.g2_find_party_species_own(ctx);T.eq(ctx.g2Var,1) + ctx.g2Var=155;Commands.g2_seen_mon(ctx);T.eq(save.pokedex.seen.CYNDAQUIL,true) + T.eq(save.pokedex.seen.SPECIES_155,nil) + Commands.g2_give_poke(ctx,133,5,0) + T.eq(#save.party,2);T.eq(save.party[2].species,'EEVEE') + -- Runtime wildcard/gift/check bytecode executes with actual native rows, + -- not a second simulated VM or a facade replacing the party Pokemon. + local Runner=require('src.script.ScriptRunner') + local runner=Runner.new(game,game.overworld) + runner:run(VM.compile(d,'GIFT')) + T.eq(runner:isRunning(),false,'real noninteractive gift program finishes') + T.eq(save.party[1].species,'CYNDAQUIL') + local found + for _,m in ipairs(save.party)do if m.isEgg and m.species=='TOGEPI'then found=m end end + T.check(found~=nil,'real native lowered giveegg creates the named hatchling') + if found then + require('src.pokemon.Gen2Breeding').hatch(game,found) + T.eq(save.flags[require('src.script.Gen2Flags').eventFlag(0x54)],true) + T.eq(save.pokedex.owned.TOGEPI,true) + end + -- A genderless Ditto must breed with a male of another egg group and + -- return that parent's actual canonical species in either slot order. + d.pokemon.DITTO.eggGroups={'DITTO'};d.pokemon.DITTO.genderRatio=255 + d.pokemon.CYNDAQUIL.eggGroups={'FIELD'} + local ditto,other=mon(132),mon(155) + other.dvs.attack=15;other.dvs.defense=3;ditto.dvs.defense=6 + for _,parents in ipairs{{ditto,other},{other,ditto}}do + local s={daycare={breed={{mon=parents[1]},{mon=parents[2]}}}} + local a,b=DC.pair(d,s);T.eq(a,parents[1]);T.eq(b,parents[2]) + T.eq(DC.eggSpecies(d,s),'CYNDAQUIL') + end + local s={daycare={breed={{mon=ditto},{mon=mon(132)}}}} + T.eq(DC.pair(d,s),nil,'two canonical Dittos cannot breed') + local nidoran=mon(29) + s={daycare={breed={{mon=ditto},{mon=nidoran}}}} + local random=math.random + math.random=function()return 127 end;T.eq(DC.eggSpecies(d,s),'NIDORAN_F') + math.random=function()return 128 end;T.eq(DC.eggSpecies(d,s),'NIDORAN_M') + math.random=random + -- Roaming encounters, once staged, also expose the canonical identity. + Commands.g2_init_roam_mons(ctx) + T.eq(save.g2Roam.RAIKOU.speciesId,'RAIKOU') + T.eq(save.g2Roam.ENTEI.speciesId,'ENTEI') + +end +V.set(old) +T.finish('native canonical GSC script operands, gifts, breeding and hatch ownership') diff --git a/tests/engine/gen2_species_runtime.lua b/tests/engine/gen2_species_runtime.lua new file mode 100644 index 000000000..73a13efd8 --- /dev/null +++ b/tests/engine/gen2_species_runtime.lua @@ -0,0 +1,123 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local old=V.get() +local Names=require('src.core.Gen2SpeciesNames') +local F=require('tests.gsc_species_fixture') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Merge=require('src.mods.Merge') +local Registry=require('src.mods.Registry') +local function count(t)local n=0;for _ in pairs(t)do n=n+1 end;return n end +for _,edition in ipairs{'gold','silver','crystal'}do + V.set(edition) + local d=F.new();local p=d.pokemon;local cyndaquil=p.SPECIES_155 + local order=d.constants.speciesOrder + p.SPECIES_155.evolutions={{method='LEVEL',level=14,species='SPECIES_156'}} + d.encounters.TEST={slots={{species='SPECIES_155',level=5}}} + d.identityTest={SPECIES_155={species='SPECIES_155'}} + d.text.IDENTITY_LITERAL='SPECIES_155' + d.field.identityTest={name='SPECIES_155',source='SPECIES_155',path='SPECIES_155',species='SPECIES_155'} + local entry=d.identityTest.SPECIES_155 + -- Exercise the real pre-loader publication pass, including its second + -- post-mod invocation. Existing tables/art and numeric indices survive. + d:seedDefaults() + T.eq(d.constants.speciesIdentity,'gsc-canonical-v1') + T.eq(d.pokemon,p);T.eq(d.pokemon.CYNDAQUIL,cyndaquil) + T.eq(d.pokemon.SPECIES_155,cyndaquil);T.eq(rawget(p,'SPECIES_155'),nil) + T.eq(count(p),251,'exactly one iterated definition per native species') + T.eq(cyndaquil.id,'CYNDAQUIL');T.eq(cyndaquil.index,155);T.eq(cyndaquil.dex,155) + T.eq(cyndaquil.name,'Localized display name');T.eq(cyndaquil.source,'ROM:SPECIES_155') + T.eq(cyndaquil.spriteFront,'tests/fixture_data/assets/fixmon_a_front.png') + T.eq(cyndaquil.evolutions[1].species,'QUILAVA') + T.eq(d.constants.speciesOrder,order);T.eq(order[155],'CYNDAQUIL') + T.eq(d.encounters.TEST.slots[1].species,'CYNDAQUIL') + T.eq(d.identityTest.CYNDAQUIL,entry);T.eq(entry.species,'CYNDAQUIL') + T.eq(d.text.IDENTITY_LITERAL,'SPECIES_155','literal text never rewritten') + for _,key in ipairs{'name','source','path'}do T.eq(d.field.identityTest[key],'SPECIES_155')end + T.eq(d.field.identityTest.species,'CYNDAQUIL') + T.eq(p.FARFETCHD,p.FARFETCH_D);T.eq(p.MR_MIME,p.MR__MIME) + d:seedDefaults();T.eq(count(p),251);T.eq(p.CYNDAQUIL,cyndaquil) + for index=1,251 do + local id=Names.fromNumber(d,index) + T.eq(p[id].id,id);T.eq(Names.number(d,id),index) + T.eq(Names.registryId(p,string.format('SPECIES_%03d',index)),id) + local mon=Pokemon.new(d,string.format('SPECIES_%03d',index),5,function(lo)return lo end) + T.eq(mon.species,id,'actual native constructor exposes stable named identity') + end + local made=Pokemon.new(d,'CYNDAQUIL',5,function(lo)return lo end) + T.eq(made.species,'CYNDAQUIL') + -- Old and named addresses must share the real registry's edit/conflict/ + -- rollback/removal path, with no duplicate alias definitions after merge. + local r=Registry.new('pokemon',require('src.mods.Schemas').REGISTRIES.pokemon) + r.base=function()return p end + r:patch('SPECIES_155',{baseExp=17},'old') + r:patch('CYNDAQUIL',{baseStats={hp=73}},'new') + T.eq(r:get('SPECIES_155'),r:get('CYNDAQUIL'));T.eq(#r:claimants('CYNDAQUIL'),2) + r:rollback('old');Names.write(p,r) + T.eq(p.CYNDAQUIL.baseStats.hp,73);T.eq(p.CYNDAQUIL.baseExp,cyndaquil.baseExp) + r=Registry.new('pokemon',require('src.mods.Schemas').REGISTRIES.pokemon);r.base=function()return p end + local replacement=Merge.deepCopy(cyndaquil);replacement.id='SPECIES_155' + r:override('SPECIES_155',replacement,'replace');Names.write(p,r) + T.eq(p.CYNDAQUIL.id,'CYNDAQUIL');T.eq(count(p),251) + -- Validate a real save codec round trip: party, sparse PC, both day-care + -- parents, pending egg, Hall of Fame, dex flags and roaming species. + local function legacy(index) + local mon=Pokemon.new(d,Names.fromNumber(d,index),5,function(lo)return lo end) + mon.species=string.format('SPECIES_%03d',index) + mon.hp=3;mon.moves[1].pp=2;mon.nickname='SPECIES_155' + return mon + end + local s={party={legacy(155)},boxes={{[20]=legacy(133)}}, + daycare={mon=legacy(25),breed={{mon=legacy(132)},{mon=legacy(29)},egg=legacy(175)}}, + player={name='TEST',id=17,map='FIX_TOWN',x=1,y=1},flags={}, + hallOfFame={{{species='SPECIES_155',level=5}}}, + g2SecondMonBackup={legacy(152)},g2BugContest={party={legacy(158)}}, + g2PokeonlySpecies='SPECIES_155',g2Roam={RAIKOU={speciesId='SPECIES_243',hp=7}}, + pokedex={seen={SPECIES_155=true,CYNDAQUIL=false},owned={SPECIES_133=true}}} + local encoded=Save.encode(s);local restored=assert(Save.decode(encoded)) + local report=Save.validate(restored,d) + T.eq(#report.lostMons,0);T.eq(#report.lostItems,0) + T.eq(restored.party[1].species,'CYNDAQUIL');T.eq(restored.boxes[1][20].species,'EEVEE') + T.eq(restored.daycare.mon.species,'PIKACHU') + T.eq(restored.daycare.breed[1].mon.species,'DITTO') + T.eq(restored.daycare.breed[2].mon.species,'NIDORAN_F') + T.eq(restored.daycare.breed.egg.species,'TOGEPI') + T.eq(restored.hallOfFame[1][1].species,'CYNDAQUIL') + T.eq(restored.g2SecondMonBackup[1].species,'CHIKORITA') + T.eq(restored.g2BugContest.party[1].species,'TOTODILE') + T.eq(restored.g2Roam.RAIKOU.speciesId,'RAIKOU');T.eq(restored.g2Roam.RAIKOU.hp,7) + T.eq(restored.g2PokeonlySpecies,'CYNDAQUIL') + T.eq(restored.pokedex.seen.CYNDAQUIL,true);T.eq(restored.pokedex.seen.SPECIES_155,nil) + T.eq(restored.pokedex.owned.EEVEE,true) + T.eq(restored.party[1].hp,3);T.eq(restored.party[1].moves[1].pp,2) + T.eq(restored.party[1].nickname,'SPECIES_155','nicknames are literal user data') + T.eq(s.party[1].species,'SPECIES_155','decode/validate never mutates source save') + local cw=require('src.save_convert.Gen2Save').crosswalks(d) + T.eq(cw.pokemonByIndex[155],'CYNDAQUIL');T.eq(cw.pokemonIndex.CYNDAQUIL,155) + -- Legacy removal cannot leave the old alias serving a cached definition. + r=Registry.new('pokemon',require('src.mods.Schemas').REGISTRIES.pokemon);r.base=function()return p end + r:remove('SPECIES_155','remove');Names.write(p,r) + T.eq(p.CYNDAQUIL,nil);T.eq(p.SPECIES_155,nil) + d:seedDefaults();T.eq(p.CYNDAQUIL,nil) +end +V.set('crystal') +for _,kind in ipairs{'incomplete','different-index','authored','collision','alias-collision','lookup'}do + local d=F.new();local p=d.pokemon;local mt + if kind=='incomplete'then p.SPECIES_251=nil + elseif kind=='different-index'then p.SPECIES_155.dex=1 + elseif kind=='authored'then p.CYNDAQUIL={id='CYNDAQUIL'} + elseif kind=='collision'then d.extra={SPECIES_155=1,CYNDAQUIL=2} + elseif kind=='alias-collision'then d.extra={SPECIES_083=1,FARFETCH_D=2} + else mt={__index=function()return 'authored'end};setmetatable(p,mt)end + T.eq(Names.canonicalize(d),false,kind..' is rejected atomically') + T.eq(p.SPECIES_155.id,'SPECIES_155');T.eq(d.constants.speciesOrder[155],'SPECIES_155') + T.eq(getmetatable(p),mt) +end +for _,edition in ipairs{'red','blue','yellow','firered','emerald','prism','polishedcrystal'}do + V.set(edition);local d=F.new() + T.eq(Names.canonicalize(d),false,edition..' own address space retained') + T.eq(d.pokemon.SPECIES_155.id,'SPECIES_155') +end +V.set(old) +T.finish('native GSC canonical runtime graph, registry edits and legacy save round trips') From fabe77ee4956bbada8bceff41d8b886a2691375a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:25:09 +0100 Subject: [PATCH 120/187] Publish finalized native GSC appearance through upstream owners --- .github/workflows/gen2-species-runtime.yml | 1 + docs/native-gen2-appearance-identity.md | 36 ++++++ src/battle/BattleState.lua | 8 +- src/core/SaveData.lua | 1 + src/pokemon/DayCare.lua | 8 +- src/pokemon/Evolution.lua | 1 + src/pokemon/Gen2Breeding.lua | 17 ++- src/pokemon/IdentityRolls.lua | 107 +++++++++++++++++ src/pokemon/Pokemon.lua | 57 ++++++++- src/script/Gen2Commands.lua | 30 ++--- src/ui/SaveCompanion.lua | 1 + tests/engine/gen2_appearance_identity.lua | 133 +++++++++++++++++++++ 12 files changed, 368 insertions(+), 32 deletions(-) create mode 100644 docs/native-gen2-appearance-identity.md create mode 100644 src/pokemon/IdentityRolls.lua create mode 100644 tests/engine/gen2_appearance_identity.lua diff --git a/.github/workflows/gen2-species-runtime.yml b/.github/workflows/gen2-species-runtime.yml index f2ca48429..5b38bcb53 100644 --- a/.github/workflows/gen2-species-runtime.yml +++ b/.github/workflows/gen2-species-runtime.yml @@ -15,3 +15,4 @@ jobs: run: | luajit tests/engine/gen2_species_runtime.lua luajit tests/engine/gen2_species_gameplay.lua + luajit tests/engine/gen2_appearance_identity.lua diff --git a/docs/native-gen2-appearance-identity.md b/docs/native-gen2-appearance-identity.md new file mode 100644 index 000000000..27cb65a67 --- /dev/null +++ b/docs/native-gen2-appearance-identity.md @@ -0,0 +1,36 @@ +# Native Gen2 appearance identity + +The real Gen2 Pokemon constructor now publishes `gender` and `shiny`, including +ordinary births without a hook subscriber. Previously the native predicates +could answer these queries, but unchanged consumers reading the Pokemon fields +directly received nil. This could select a normal model variant for a female. + +`opts.dvs` accepts the four finalized native 0–15 DVs for fixed-gene births. +The constructor owns a copy, derives HP DV and spends no discarded random DV +roll. Trainer parties, NPC trades, Odd Eggs and inherited day-care eggs use this +path before appearance hooks run. Definitions and other Pokemon do not share +the mutable gene table. Ordinary native births still use four random DVs. + +Save validation fills absent appearance fields on actual known Pokemon after +gene/level validation. Explicit saved values survive loading. Summary/stat +refresh, evolution, hatching, forced-shiny gene changes and companion stat edits +refresh the actual identity. True shininess remains sticky, matching the Gen1 +Gen2 refresh contract. Refresh does not change HP, stats, genes, moves or EXP +by itself. Hook context edits retain their existing ownership contract. + +`tests/engine/gen2_appearance_identity.lua` covers all seven gender-ratio +classes, DV boundaries and shiny attack classes in Gold, Silver and Crystal; +save codec/validation for party, sparse boxes, day care and contest parties; +fixed trainer genes, Odd Eggs, actual DayCare.step and evolution. The existing +identity authority/roll suites cover hook delegation, suppression, forced shiny +and failed callback rollback across generations and API formats. + +These are engine behavior checks. They do not certify GPU model loading, +complete menu/battle appearance parity, optional asset availability, or overall +release readiness. No mod changes are needed for this engine contract. + +Upstream adaptation: based on the native species/runtime series in PR371. The +existing extra-shiny option remains in the upstream birth path. IdentityRolls +is the exact helper already offered in PR169; this change does not introduce +a second hook contract or advertise unsupported public hook capability. +The native-only regression is independent of that SDK integration. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 1b76d11cf..3ae88db57 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1695,15 +1695,17 @@ local function buildTrainerParty(game, oppClass, partyIndex, partyDef) goto continue end do - local mon = Pokemon.new(game.data, slot.species, slot.level, nil, slot.form) + local dvs = slot.dvs or trainerDvs + local gen2 = require('src.core.GameVersion').generation()==2 + local mon = Pokemon.new(game.data, slot.species, slot.level, nil, slot.form, + gen2 and {dvs=dvs} or nil) -- fixed trainer DVs, recomputed stats. A party slot that carries its OWN -- DVs and stat exp is a stored mon rather than a generated one -- the -- Battle Tower's opponents come out of BattleTowerMons with both -- so its -- numbers win, and Stats.calc reproduces the cartridge's own stat block -- from them. No ordinary TrainerGroups slot has either field, so every -- other trainer in the game is unchanged. - local dvs = slot.dvs or trainerDvs - mon.dvs = dvs + if gen2 then dvs=mon.dvs else mon.dvs=dvs end if slot.statExp then mon.statExp = slot.statExp end if slot.happiness then mon.happiness = slot.happiness end if slot.stats then diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 39c6bec88..8eacc886f 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1986,6 +1986,7 @@ local function scrubKnownMon(mon, data) for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end end mon.level = clamp(mon.level, 1, 100, 1) + require('src.pokemon.Pokemon').ensureIdentity(data,mon) -- Box mons imported from a real .sav carry NO stat block: box_struct stops -- before MON_LEVEL/MON_STATS, so src/save_convert/GenSave.lua decodeMon -- only fills `stats` for party slots. Every HP-bar draw then nil-indexes diff --git a/src/pokemon/DayCare.lua b/src/pokemon/DayCare.lua index 9b54b19d7..6c26e4b36 100644 --- a/src/pokemon/DayCare.lua +++ b/src/pokemon/DayCare.lua @@ -593,11 +593,15 @@ function DayCare.step(data, save, expPerStep) local species = DayCare.eggSpecies(data, save) if not species then return false end local Pokemon = require("src.pokemon.Pokemon") - local egg = Pokemon.new(data, species, 5) + local eggDvs + if vanillaGen2 then + eggDvs=require('src.pokemon.Stats').randomDVs() + require('src.pokemon.Gen2Breeding').inheritedDVs(data,save,species,eggDvs) + end + local egg = Pokemon.new(data, species, 5, nil, nil, eggDvs and {dvs=eggDvs} or nil) if version=="emerald" then require("src.pokemon.Gen3Breeding").apply(data,save,egg) end if vanillaGen2 then local Breeding = require("src.pokemon.Gen2Breeding") - Breeding.inheritDVs(data, save, egg) Breeding.setOwner(save, egg) egg.hp = 0 -- DayCare_GiveEgg initializes current HP to zero. end diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index e9506843f..a2040bf2a 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -385,6 +385,7 @@ function Evolution.apply(game, mon, newSpecies, via, evo) mon.stats = Stats.calc(newDef, mon.level, mon.dvs, mon.statExp) end mon.hp = math.max(1, mon.stats.hp - hpLost) + require('src.pokemon.Pokemon').syncIdentity(game.data,mon) if game.save.pokedex then game.save.pokedex.seen[newSpecies] = true game.save.pokedex.owned[newSpecies] = true diff --git a/src/pokemon/Gen2Breeding.lua b/src/pokemon/Gen2Breeding.lua index 233ac8b4e..a7567c20f 100644 --- a/src/pokemon/Gen2Breeding.lua +++ b/src/pokemon/Gen2Breeding.lua @@ -6,25 +6,31 @@ function Breeding.isVanilla() return version == "gold" or version == "silver" or version == "crystal" end -function Breeding.inheritDVs(data, save, egg) +-- Finalize the owned birth genes before Pokemon.new publishes appearance. +function Breeding.inheritedDVs(data, save, species, dvs) local DC = require("src.pokemon.DayCare") local a, b = DC.mon(save,1), DC.mon(save,2) - if not (a and b and egg.dvs) then return false end + if not (a and b and dvs) then return false end local parent if DC.isDitto(data,a) then parent=a elseif DC.isDitto(data,b) then parent=b else local mother, father = DC.parents(data,save) - local gender = DC.gender(data,egg) + local gender = DC.gender(data,{species=species,dvs=dvs}) if gender == "male" then parent=mother elseif gender == "female" then parent=father end end if not (parent and parent.dvs and parent.dvs.defense and parent.dvs.special) then return false end - local dvs = egg.dvs dvs.defense = parent.dvs.defense dvs.special = math.floor(dvs.special / 8) * 8 + parent.dvs.special % 8 dvs.hp = dvs.attack % 2 * 8 + dvs.defense % 2 * 4 + dvs.speed % 2 * 2 + dvs.special % 2 - egg.stats = require("src.pokemon.Stats").calc(data.pokemon[egg.species],egg.level,dvs,egg.statExp) + return true +end + +function Breeding.inheritDVs(data, save, egg) + if not Breeding.inheritedDVs(data,save,egg.species,egg.dvs) then return false end + egg.stats = require("src.pokemon.Stats").calc(data.pokemon[egg.species],egg.level,egg.dvs,egg.statExp) + require('src.pokemon.Pokemon').syncIdentity(data,egg) return true end @@ -37,6 +43,7 @@ function Breeding.hatch(game, mon) Breeding.setOwner(game.save,mon) local def=game.data.pokemon[mon.species] if def then mon.stats=require("src.pokemon.Stats").calc(def,mon.level,mon.dvs,mon.statExp) end + require('src.pokemon.Pokemon').syncIdentity(game.data,mon) local dex=game.save.pokedex or {} game.save.pokedex=dex dex.seen,dex.owned=dex.seen or {},dex.owned or {} diff --git a/src/pokemon/IdentityRolls.lua b/src/pokemon/IdentityRolls.lua new file mode 100644 index 000000000..5c8d13967 --- /dev/null +++ b/src/pokemon/IdentityRolls.lua @@ -0,0 +1,107 @@ +-- Shared hook results, with each generation's actual DV/PID predicate. +-- Native predicates draw no RNG; explicit Gen-2 context edits retain native +-- write-back rather than silently changing the saved identity on an override. +local Runtime = require("src.mods.Runtime") +local Stats = require("src.pokemon.Stats") +local bit = require("bit") +local R = {} +local GENDERS = { male = true, female = true, unknown = true } + +function R.data(data) + return data or Runtime.hooks.data +end + +local function definition(data, species, generation) + local def = data and data.pokemon and data.pokemon[species] + if generation ~= 3 or type(def) ~= "table" then + return def + end + -- GBA's species_hook_def is a call-local metadata record. Threshold + -- steering changes this evaluation, not the mounted species table. + local out = {} + for k, v in pairs(def) do + out[k] = v + end + out.id = species + return out +end + +function R.nativeGender(def, dvs, personality, generation) + local ratio = def and def.genderRatio + if ratio == nil or ratio == 255 then + return "unknown" + end + if ratio == 0 then + return "male" + end + if ratio == 254 then + return "female" + end + if generation == 3 then + return ratio > ((tonumber(personality) or 0) % 256) and "female" or "male" + end + local value = (dvs and dvs.attack or 0) * 16 + (dvs and dvs.speed or 0) + return value <= ratio and "female" or "male" +end + +function R.gender(data, mon, generation, level) + data = R.data(data) + local def = definition(data, mon.species, generation) + local fallback = R.nativeGender(def, mon.dvs, mon.personality, generation) + local function vanilla(c) + return R.nativeGender(c.def, c.dvs, c.personality, generation) + end + local ctx = { species = mon.species, def = def, ratio = def and def.genderRatio } + if generation == 2 then + ctx.dvs, ctx.level = mon.dvs, level + else + ctx.personality = mon.personality + end + local result = Runtime.call("gender.roll", vanilla, ctx) + if not GENDERS[result] then + return generation == 3 and fallback or R.nativeGender(def, mon.dvs, mon.personality, generation) + end + return result +end + +function R.gen2Shiny(mon, def) + local result = Runtime.call("shiny.roll", function(c) + return Stats.isShiny(c.dvs) + end, { dvs = mon.dvs, species = mon.species, def = def, level = mon.level }) + return not not result +end + +local function nativeShiny(personality, otId, otSecretId) + local p = (tonumber(personality) or 0) % 4294967296 + local full = (tonumber(otId) or 0) % 4294967296 + local tid = full % 65536 + local sid = (tonumber(otSecretId) or math.floor(full / 65536)) % 65536 + return bit.bxor(tid, sid, math.floor(p / 65536), p % 65536) < 8 +end +R.nativeGen3Shiny = nativeShiny + +function R.gen3Shiny(mon, data) + data = R.data(data) + local def = definition(data, mon.species, 3) + local ivs + if type(mon.ivs) == "table" then + ivs = {} + for k, v in pairs(mon.ivs) do + ivs[k] = v + end + end + local result = Runtime.call("shiny.roll", function(c) + return nativeShiny(c.personality, c.otId, c.otSecretId) + end, { + species = mon.species, + def = def, + level = mon.level, + personality = mon.personality, + ivs = ivs, + otId = mon.otId or mon.trainerId, + otSecretId = mon.otSecretId, + }) + return not not result +end + +return R diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 3e97dd6e4..edd1effee 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -173,8 +173,9 @@ function Pokemon.applySeed(data, mon, seed) return true end -function Pokemon.new(data, species, level, rng, form) +function Pokemon.new(data, species, level, rng, form, opts) species=require("src.core.Gen2SpeciesNames").registryId(data.pokemon,species) + local generation=require("src.core.GameVersion").generation() local nativeForm=(data.constants or {}).gen==4 and (form or 0) or nil local speciesDef=(data.pokemon or {})[species] if require("src.core.GameVersion").get()=="polishedcrystal" and speciesDef and speciesDef.forms then @@ -183,7 +184,20 @@ function Pokemon.new(data, species, level, rng, form) local def = require('src.pokemon.Gen4Forms').definition(data, {species=species,form=nativeForm}) assert(def, "unknown species " .. tostring(species)) - local dvs = Stats.randomDVs(rng) + local dvs + if generation == 2 and opts and type(opts.dvs) == "table" then + -- Trainer/trade/egg genes are already decided. Own a copy before any + -- identity hook can edit it; do not spend a random birth roll first. + dvs = {} + for _,key in ipairs({'attack','defense','speed','special'})do + local n=opts.dvs[key] + assert(type(n)=='number'and n%1==0 and n>=0 and n<=15,'invalid native Gen2 DV: '..key) + dvs[key]=n + end + dvs.hp=dvs.attack%2*8+dvs.defense%2*4+dvs.speed%2*2+dvs.special%2 + else + dvs = Stats.randomDVs(rng) + end local seed = Stats.isGen3(def) and gen3Seed(data, def, rng) or nil local stats = seed and Stats.calc(def, level, seed.ivs, nil, seed.evs, seed.nature) @@ -243,7 +257,40 @@ function Pokemon.new(data, species, level, rng, form) -- meaning "in tall grass only". At the default setting shinyOddsOf answers -- nil and this returns immediately, leaving the cartridge's own 1/8192 -- exactly as it was. - Pokemon.rollShiny(data, nil, mon, rng) + if generation==2 and opts and opts.shiny then mon.shiny=true + else Pokemon.rollShiny(data, nil, mon, rng) end + if generation==2 then + local Rolls=require('src.pokemon.IdentityRolls') + mon.shiny=mon.shiny or Rolls.gen2Shiny(mon,def) + mon.gender=Rolls.gender(data,mon,2,level) + end + return mon +end + +-- Old native saves predate the public appearance fields. Populate only absent +-- fields at validation, on the actual Pokemon, without a random re-roll or +-- replacing explicit saved identity. Normal refresh remains the owner below. +function Pokemon.ensureIdentity(data,mon) + if type(mon)~='table'or type(mon.dvs)~='table' + or require('src.core.GameVersion').generation()~=2 then return mon end + local def=data and data.pokemon and data.pokemon[mon.species] + if not def then return mon end + local Rolls=require('src.pokemon.IdentityRolls') + if mon.gender==nil then mon.gender=Rolls.gender(data,mon,2,mon.level)end + if mon.shiny==nil then mon.shiny=Rolls.gen2Shiny(mon,def)end + return mon +end + +-- Gen 2 refreshes identity at its summary/stat-refresh boundary. A true +-- shiny stays authoritative; a cached false and gender are read again. +function Pokemon.syncIdentity(data, mon) + if type(mon) ~= "table" or type(mon.dvs)~='table' + or require("src.core.GameVersion").generation() ~= 2 then return mon end + local Rolls = require("src.pokemon.IdentityRolls") + local def = data and data.pokemon and data.pokemon[mon.species] + if not def then return mon end + mon.gender = Rolls.gender(data, mon, 2, mon.level) + mon.shiny = mon.shiny or Rolls.gen2Shiny(mon, def) return mon end @@ -311,6 +358,7 @@ function Pokemon.forceShiny(data, mon, rng, otId) mon.hp = (full and mon.hp and mon.hp < full) and math.min(mon.hp, mon.stats.hp) or mon.stats.hp end + Pokemon.syncIdentity(data,mon) return mon end @@ -396,6 +444,9 @@ function Pokemon.isShiny(mon) -- before its OT was stamped return mon.shiny == true end + if require('src.core.GameVersion').generation()==2 and mon.shiny~=nil then + return mon.shiny==true + end if type(mon.dvs) == "table" then return Stats.isShiny(mon.dvs) end return mon.shiny == true end diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index 8b26df5c9..74487b468 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2416,21 +2416,18 @@ function Commands.g2_give_odd_egg(ctx) end local row = eggs[pick] or eggs[1] local Pokemon = require("src.pokemon.Pokemon") - local mon = Pokemon.new(data, row.species, row.level or 5) + local eggDvs if type(row.dvs) == "table" and row.dvs[1] and row.dvs[2] then local one, two = row.dvs[1], row.dvs[2] local atk, dfn = math.floor(one / 16) % 16, one % 16 local spd, spc = math.floor(two / 16) % 16, two % 16 - mon.dvs = { + eggDvs = { attack = atk, defense = dfn, speed = spd, special = spc, hp = (atk % 2) * 8 + (dfn % 2) * 4 + (spd % 2) * 2 + (spc % 2), } - pcall(function() - mon.stats = require("src.pokemon.Stats") - .calc(data.pokemon[row.species], mon.level, mon.dvs, mon.statExp) - mon.hp = mon.stats.hp - end) end + local mon = Pokemon.new(data, row.species, row.level or 5, nil, nil, + eggDvs and {dvs=eggDvs} or nil) if row.moves then mon.moves = {} for _, id in ipairs(row.moves) do @@ -3668,10 +3665,7 @@ function Commands.g2_trade(ctx, index) -- recalculates its stats from those DVs at that level). local Pokemon = require("src.pokemon.Pokemon") local sent = party[slot] - local newMon = Pokemon.new(data, trade.get, sent.level) - if trade.nickname and trade.nickname ~= "" then - newMon.nickname = trade.nickname - end + local tradeDvs -- The table's two DV bytes are the usual packed nibble pairs -- attack and -- defense in the first, speed and special in the second -- and the HP DV is -- the four low bits, exactly as Stats.randomDVs derives it. @@ -3679,17 +3673,15 @@ function Commands.g2_trade(ctx, index) local one, two = trade.dvs[1], trade.dvs[2] local atk, dfn = math.floor(one / 16) % 16, one % 16 local spd, spc = math.floor(two / 16) % 16, two % 16 - local dvs = { + tradeDvs = { attack = atk, defense = dfn, speed = spd, special = spc, hp = (atk % 2) * 8 + (dfn % 2) * 4 + (spd % 2) * 2 + (spc % 2), } - newMon.dvs = dvs - pcall(function() - local speciesDef = data.pokemon[trade.get] - newMon.stats = require("src.pokemon.Stats") - .calc(speciesDef, newMon.level, dvs, newMon.statExp) - newMon.hp = newMon.stats.hp - end) + end + local newMon = Pokemon.new(data, trade.get, sent.level, nil, nil, + tradeDvs and {dvs=tradeDvs} or nil) + if trade.nickname and trade.nickname ~= "" then + newMon.nickname = trade.nickname end newMon.traded = true -- boosted exp, and the Name Rater refuses it newMon.ot = (trade.ot ~= "" and trade.ot) or "TRAINER" diff --git a/src/ui/SaveCompanion.lua b/src/ui/SaveCompanion.lua index 9ba0e5112..6bf043091 100644 --- a/src/ui/SaveCompanion.lua +++ b/src/ui/SaveCompanion.lua @@ -140,6 +140,7 @@ local function recalc(game,mon) mon.stats=Stats.calc(def,mon.level or 1,mon.dvs or {},mon.statExp) end mon.hp=clamp(tonumber(mon.hp) or (mon.stats.hp or 1),0,mon.stats.hp or 1) + require('src.pokemon.Pokemon').syncIdentity(game.data,mon) end local function setLevel(game,mon,delta) if not mon then return end diff --git a/tests/engine/gen2_appearance_identity.lua b/tests/engine/gen2_appearance_identity.lua new file mode 100644 index 000000000..760283a7a --- /dev/null +++ b/tests/engine/gen2_appearance_identity.lua @@ -0,0 +1,133 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local old=V.get() +local P=require('src.pokemon.Pokemon') +local Stats=require('src.pokemon.Stats') +local Save=require('src.core.SaveData') +local Copy=require('src.mods.Merge').deepCopy +local F=require('tests.gsc_species_fixture') +require('src.script.Gen2Commands') +local function genes(a,d,s,c)return {attack=a,defense=d,speed=s,special=c}end +local function noRoll()error('fixed genes must not draw random birth DVs')end +for _,edition in ipairs{'gold','silver','crystal'}do + V.set(edition);local data=F.new();data:seedDefaults() + -- All ratio classes, both sides of thresholds and all shiny Attack-DV + -- classes. Consumers must be able to read the actual saved fields directly. + for _,ratio in ipairs{0,31,63,127,191,254,255}do + data.pokemon.CYNDAQUIL.genderRatio=ratio + for attack=0,15 do for _,speed in ipairs{0,10,15}do + local dvs=genes(attack,10,speed,10);local before=Copy(dvs) + local mon=P.new(data,'CYNDAQUIL',5,noRoll,nil,{dvs=dvs}) + local expected=ratio==255 and 'unknown' or ratio==0 and 'male' + or ratio==254 and 'female' or (attack*16+speed<=ratio and 'female' or 'male') + T.eq(mon.gender,expected,'native public birth gender '..edition..'/'..ratio) + T.eq(mon.shiny,Stats.isShiny(dvs),'native public birth shiny') + T.same(dvs,before,'constructor never edits caller genes') + T.check(mon.dvs~=dvs,'native Pokemon owns its gene table') + T.eq(mon.dvs.hp,attack%2*8+speed%2*2,'HP DV derived from final genes') + end end + end + data.pokemon.CYNDAQUIL.genderRatio=127 + local draws=0 + local born=P.new(data,'CYNDAQUIL',5,function(lo,hi)draws=draws+1;return lo end) + T.eq(draws,4,'ordinary birth still draws exactly four native DVs') + T.eq(born.gender,'female');T.eq(born.shiny,false) + for _,bad in ipairs{-1,16,0.5,'3'}do + local dvs=genes(0,0,0,0);dvs.attack=bad + T.raises(function()P.new(data,'CYNDAQUIL',5,noRoll,nil,{dvs=dvs})end,'invalid native Gen2 DV') + end + local shiny=genes(2,10,10,10) + local function make(dvs)return P.new(data,'CYNDAQUIL',5,noRoll,nil,{dvs=dvs or shiny})end + -- Loading a pre-field save goes through the actual codec and validator, + -- including sparse boxes, day care, pending eggs and held contest parties. + local function legacy() + local m=make();m.gender=nil;m.shiny=nil;m.hp=3;m.moves[1].pp=1 + return m + end + local s={party={legacy()},boxes={{[20]=legacy()}}, + daycare={mon=legacy(),breed={{mon=legacy()},{mon=legacy()},egg=legacy()}}, + g2SecondMonBackup={legacy()},g2BugContest={party={legacy()}}, + player={name='TEST',id=17,map='FIX_TOWN',x=1,y=1},flags={}, + pokedex={seen={},owned={}},hallOfFame={{{species='CYNDAQUIL',level=5}}}} + local restored=assert(Save.decode(Save.encode(s))) + Save.validate(restored,data) + for _,m in ipairs{restored.party[1],restored.boxes[1][20],restored.daycare.mon, + restored.daycare.breed[1].mon,restored.daycare.breed[2].mon, + restored.daycare.breed.egg,restored.g2SecondMonBackup[1],restored.g2BugContest.party[1]}do + T.eq(m.gender,'female','validated live/save Pokemon has appearance') + T.eq(m.shiny,true);T.eq(m.hp,3);T.eq(m.moves[1].pp,1) + T.same(m.dvs,make().dvs,'validation preserves final genes') + end + T.eq(s.party[1].gender,nil,'source checkpoint untouched') + T.eq(restored.hallOfFame[1][1].gender,nil,'minimal record gets no invented genes') + local explicit=make();explicit.gender='male';explicit.shiny=false + P.ensureIdentity(data,explicit) + T.eq(explicit.gender,'male');T.eq(explicit.shiny,false,'saved explicit false stays authoritative on load') + local snapshot={dvs=Copy(explicit.dvs),stats=Copy(explicit.stats),hp=explicit.hp,exp=explicit.exp,moves=Copy(explicit.moves)} + P.syncIdentity(data,explicit) + T.eq(explicit.gender,'female');T.eq(explicit.shiny,true,'stat/summary refresh reads final genes') + T.same({dvs=explicit.dvs,stats=explicit.stats,hp=explicit.hp,exp=explicit.exp,moves=explicit.moves},snapshot) + explicit.dvs=genes(15,0,15,0);P.syncIdentity(data,explicit) + T.eq(explicit.gender,'male');T.eq(explicit.shiny,true,'forced true remains sticky at refresh') + local incomplete={species='CYNDAQUIL'};P.syncIdentity(data,incomplete);P.ensureIdentity(data,incomplete) + T.same(incomplete,{species='CYNDAQUIL'},'no invented genes for incomplete records') + + -- Final trainer genes are installed before the one actual birth callback; + -- two trainer slots never share their mutable DVs with each other or data. + local slot={species='CYNDAQUIL',level=5,dvs=Copy(shiny)} + data.trainers.APPEARANCE={name='TEST',parties={{slot,slot}}} + local game={data=data,save=Save.newGame(),stack={top=function()end}} + game.save.party={make()} + local battle=require('src.battle.BattleState').newTrainer(game,'APPEARANCE',1) + for _,m in ipairs(battle.enemyParty)do T.eq(m.shiny,true);T.eq(m.gender,'female')end + battle.enemyParty[1].dvs.attack=15 + T.eq(battle.enemyParty[2].dvs.attack,2);T.eq(slot.dvs.attack,2) + + -- The cartridge's Odd Egg is a fixed-gene gift, not a random birth whose + -- cached identity survives replacing DVs with the distribution record. + data.field.gen2OddEggs={eggs={{species='CYNDAQUIL',level=5,dvs={0x2a,0xaa}}},probabilities={65535}} + local oddSave={party={}} + require('src.script.Commands').g2_give_odd_egg({game={data=data},save=oddSave}) + T.eq(oddSave.party[1].shiny,true);T.eq(oddSave.party[1].gender,'female') + T.eq(oddSave.party[1].isEgg,true) + + -- Normal DayCare.step inherits before publishing the egg. The Ditto's + -- inherited Defense/Special turn an otherwise non-shiny roll into shiny. + data.pokemon.DITTO.genderRatio=255;data.pokemon.DITTO.eggGroups={'DITTO'} + local ditto=P.new(data,'DITTO',5,noRoll,nil,{dvs=genes(0,10,0,2)}) + local parent=make(genes(15,3,15,3));ditto.otId=1;parent.otId=2 + local breed={{mon=ditto},{mon=parent},stepsToEgg=1} + local dcSave={daycare={breed=breed},player={name='TEST',id=17},flags={}} + local random,mathRandom=love.math.random,math.random + local values={2,0,10,10};local i=0 + love.math.random=function()i=i+1;return assert(values[i],'extra birth roll')end + math.random=function(lo)return lo or 0 end + local ok,created=pcall(require('src.pokemon.DayCare').step,data,dcSave) + love.math.random,math.random=random,mathRandom + T.check(ok,tostring(created));T.eq(created,true,'actual day-care step creates egg') + if breed.egg then + T.eq(i,4,'inheritance consumes one native DV roll') + T.eq(breed.egg.gender,'female');T.eq(breed.egg.shiny,true) + T.eq(breed.egg.dvs.defense,10);T.eq(breed.egg.dvs.special,10) + T.eq(breed.egg.hp,0,'native egg current HP unchanged') + T.eq(ditto.dvs.special,2,'parent genes never mutated') + require('src.pokemon.Gen2Breeding').hatch({data=data,save=dcSave},breed.egg) + T.eq(breed.egg.gender,'female');T.eq(breed.egg.shiny,true) + end + -- A real evolution can cross a gender threshold (a modded species too). + local evolved=make(genes(0,0,0,0));data.pokemon.QUILAVA.genderRatio=255 + require('src.pokemon.Evolution').apply(game,evolved,'QUILAVA','level') + T.eq(evolved.gender,'unknown');T.eq(evolved.shiny,false) + T.eq(evolved.species,'QUILAVA') +end +-- No unconditional field cache or extra random birth path for other engines. +for _,edition in ipairs{'red','firered','emerald','platinum','black'}do + V.set(edition);local data=T.fixtures.fresh() + local mon=P.new(data,'FIXMON_A',5,function(lo)return lo end) + T.eq(mon.gender,nil,edition..' birth behavior unchanged without hooks') + local before=Copy(mon);P.ensureIdentity(data,mon);P.syncIdentity(data,mon) + T.same(mon,before,edition..' refresh remains unchanged') +end +V.set(old) +T.finish('native GSC appearance birth, fixed genes, saves, trainer, eggs and evolution') From d398e927c9718bafcf0459ca7379504aa8fcf018 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:28:13 +0100 Subject: [PATCH 121/187] Keep passive companion painting out of identity refresh and test trade ownership --- .github/workflows/gen2-species-runtime.yml | 1 + docs/native-gen2-appearance-identity.md | 5 +- src/ui/SaveCompanion.lua | 3 +- tests/engine/gen2_appearance_transactions.lua | 69 +++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 tests/engine/gen2_appearance_transactions.lua diff --git a/.github/workflows/gen2-species-runtime.yml b/.github/workflows/gen2-species-runtime.yml index 5b38bcb53..bdddc8dc1 100644 --- a/.github/workflows/gen2-species-runtime.yml +++ b/.github/workflows/gen2-species-runtime.yml @@ -16,3 +16,4 @@ jobs: luajit tests/engine/gen2_species_runtime.lua luajit tests/engine/gen2_species_gameplay.lua luajit tests/engine/gen2_appearance_identity.lua + luajit tests/engine/gen2_appearance_transactions.lua diff --git a/docs/native-gen2-appearance-identity.md b/docs/native-gen2-appearance-identity.md index 27cb65a67..e660d80b8 100644 --- a/docs/native-gen2-appearance-identity.md +++ b/docs/native-gen2-appearance-identity.md @@ -21,7 +21,10 @@ by itself. Hook context edits retain their existing ownership contract. `tests/engine/gen2_appearance_identity.lua` covers all seven gender-ratio classes, DV boundaries and shiny attack classes in Gold, Silver and Crystal; save codec/validation for party, sparse boxes, day care and contest parties; -fixed trainer genes, Odd Eggs, actual DayCare.step and evolution. The existing +fixed trainer genes, Odd Eggs, actual DayCare.step and evolution. `tests/engine/gen2_appearance_transactions.lua` additionally runs the actual +NPC trade script, party picker and movie continuation, plus the real companion +DV/species mutation handlers. Passive panel recalculation preserves identity +and does not dispatch appearance hooks every frame. The existing identity authority/roll suites cover hook delegation, suppression, forced shiny and failed callback rollback across generations and API formats. diff --git a/src/ui/SaveCompanion.lua b/src/ui/SaveCompanion.lua index 6bf043091..f42f6545c 100644 --- a/src/ui/SaveCompanion.lua +++ b/src/ui/SaveCompanion.lua @@ -140,7 +140,6 @@ local function recalc(game,mon) mon.stats=Stats.calc(def,mon.level or 1,mon.dvs or {},mon.statExp) end mon.hp=clamp(tonumber(mon.hp) or (mon.stats.hp or 1),0,mon.stats.hp or 1) - require('src.pokemon.Pokemon').syncIdentity(game.data,mon) end local function setLevel(game,mon,delta) if not mon then return end @@ -166,6 +165,7 @@ local function adjustIndividual(game,mon,key,delta) syncHpDv(mon.dvs) end recalc(game,mon) + Pokemon.syncIdentity(game.data,mon) end local function cycleMove(game,mon,slot,delta) ensureCatalog(game) @@ -209,6 +209,7 @@ local function applySpecies(game, picker, id) mon.species=id mon.exp=Growth.expForLevel(def.growthRate,mon.level or 1) recalc(game,mon) + Pokemon.syncIdentity(game.data,mon) state.speciesPicker=nil return end diff --git a/tests/engine/gen2_appearance_transactions.lua b/tests/engine/gen2_appearance_transactions.lua new file mode 100644 index 000000000..2ee98a3e7 --- /dev/null +++ b/tests/engine/gen2_appearance_transactions.lua @@ -0,0 +1,69 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local old=V.get() +local P=require('src.pokemon.Pokemon') +local F=require('tests.gsc_species_fixture') +local Commands=require('src.script.Commands');require('src.script.Gen2Commands') +local Stack=require('src.core.StateStack') +local Runner=require('src.script.ScriptRunner') +-- Editor handlers are private closures. Exercise the real handlers, not a +-- duplicate of their mutation rules. This is a CPU transaction test, not a +-- lower-screen device/GUI acceptance test. +local function handler(root,name) + local visited={} + local function find(fn) + if visited[fn]then return end;visited[fn]=true + for i=1,100 do + local key,value=debug.getupvalue(fn,i);if not key then break end + if key==name then return value end + if type(value)=='function'then local found=find(value);if found then return found end end + end + end + return assert(find(root),'missing actual editor handler '..name) +end +local companion=require('src.ui.SaveCompanion') +local adjust=handler(companion.tick,'adjustIndividual') +local recalc=handler(companion.tick,'recalc') +local applySpecies=handler(companion.tick,'applySpecies') +for _,edition in ipairs{'gold','silver','crystal'}do + V.set(edition);local data=F.new();data:seedDefaults() + local sent=P.new(data,'CYNDAQUIL',12,function()return 15 end) + data.field.gen2Trades={trades={{give='CYNDAQUIL',get='EEVEE',dvs={0x2a,0xaa}, + nickname='GIFT',ot='NPC',otId=17,item='POTION'}}} + local stack=setmetatable({},{__index=Stack});stack:init() + local game={data=data,save={party={sent},player={name='TEST',id=7},pokedex={seen={},owned={}}},stack=stack} + -- Dialogue IO is outside this test. Actual script, party picker, trade + -- materialization and movie still run on the engine's real owners. + local ask,show=Commands.ask,Commands.show_text + Commands.ask=function(ctx)ctx.lastCheck=true end + Commands.show_text=function()end + local runner=Runner.new(game) + runner:run({{'g2_trade',0}}) + local picker=assert(stack:top());T.eq(picker.pickOnly,true) + game.input={wasPressed=function(_,button)return button=='a'end} + picker:update(0) + local received=game.save.party[1] + T.check(received~=sent,'actual native NPC exchange replaces picked mon') + T.eq(received.species,'EEVEE');T.eq(received.level,12) + T.eq(received.shiny,true);T.eq(received.gender,'female') + T.eq(received.dvs.attack,2);T.eq(received.dvs.defense,10) + T.eq(received.nickname,'GIFT');T.eq(received.ot,'NPC');T.eq(received.item,'POTION') + local movie=assert(stack:top());T.eq(movie.received,received,'movie sees same final Pokemon') + movie:finish() + T.eq(runner:isRunning(),false,'trade script resumes through actual movie completion') + Commands.ask,Commands.show_text=ask,show + -- Passive panel recalculation must not reroll identity each frame. Actual + -- DV/species edits refresh it once at their mutation boundary instead. + received.gender='male';received.shiny=false + recalc(game,received) + T.eq(received.gender,'male');T.eq(received.shiny,false,'passive panel preserves cached identity') + adjust(game,received,'attack',0) + T.eq(received.gender,'female');T.eq(received.shiny,true) + local hp=received.hp;data.pokemon.QUILAVA.genderRatio=255 + applySpecies(game,{mode='change',mon=received},'QUILAVA') + T.eq(received.species,'QUILAVA');T.eq(received.gender,'unknown');T.eq(received.shiny,true) + T.eq(received.hp,hp,'editor identity refresh does not heal the Pokemon') +end +V.set(old) +T.finish('actual GSC NPC trade and companion appearance mutation boundaries') From 18aa200af7ae229498aec64fd5244df614e35641 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:45:56 +0100 Subject: [PATCH 122/187] Integrate Gen1 touch skin parsing and native input ownership --- NOTICE.md | 14 + licenses/GEN1RECOMP-TOUCH-SKINS.md | 759 +++++++++++ src/core/DeltaSkin.lua | 544 ++++++++ src/core/Game.lua | 36 +- src/core/PdfImage.lua | 118 ++ src/core/SkinZip.lua | 95 ++ src/core/TouchControls.lua | 235 +++- src/core/TouchSkin.lua | 1496 ++++++++++++++++++++++ src/render/GameViewport.lua | 16 +- src/render/Renderer.lua | 2 +- src/ui/TouchControlsEditor.lua | 1 + tests/engine/skin_format_import_test.lua | 704 ++++++++++ tests/engine/touch_skin_dpad_area.lua | 201 +++ tests/engine/touch_skin_test.lua | 401 ++++++ 14 files changed, 4611 insertions(+), 11 deletions(-) create mode 100644 NOTICE.md create mode 100644 licenses/GEN1RECOMP-TOUCH-SKINS.md create mode 100644 src/core/DeltaSkin.lua create mode 100644 src/core/PdfImage.lua create mode 100644 src/core/SkinZip.lua create mode 100644 src/core/TouchSkin.lua create mode 100644 tests/engine/skin_format_import_test.lua create mode 100644 tests/engine/touch_skin_dpad_area.lua create mode 100644 tests/engine/touch_skin_test.lua diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 000000000..67a3a6b5f --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,14 @@ +# Native touch-skin provenance + +The TouchSkin, DeltaSkin, SkinZip and PdfImage modules, their identified +TouchControls integration and adapted format tests originate in Gen1Recomp +commit 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 and its Section 7 terms are +retained in licenses/GEN1RECOMP-TOUCH-SKINS.md. + +Native host integration and tests: Contributions by Roxas2712 — +https://github.com/Roxas2712 + +Existing upstream and Gen1 rights and licence grants remain effective; this +notice assigns no other author's work to Roxas2712. No proprietary Gen1 +launcher code or third-party bundled skin art is included. diff --git a/licenses/GEN1RECOMP-TOUCH-SKINS.md b/licenses/GEN1RECOMP-TOUCH-SKINS.md new file mode 100644 index 000000000..806424f26 --- /dev/null +++ b/licenses/GEN1RECOMP-TOUCH-SKINS.md @@ -0,0 +1,759 @@ +# License + +Copyright (C) 2026 BOIS CLUB GAMES, LLC + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation, version 3 of the License, with the Additional Terms below, which +are added under Section 7 of that License. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +## Additional Terms (GPLv3 Section 7) + +### 1. Attribution (Section 7(b)) + +Any copy, fork, modified version, or work based on this program, in source or +object form, and for any purpose, must: + +- keep this license file and every copyright and author notice in the source; +- give clear, visible credit to the original project in its README (or + equivalent project page) and in any in-app credits, "about", or title + screen, reading at minimum: + "Based on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC + (https://github.com/bryanthaboi/gen1recomp)"; +- show that same credit, visibly, inside any launcher, front end, menu shell, + or other program used to start or select the game, including any launcher + written to replace the one excluded in term 2. + +Using this program, in whole or in part, for other projects is permitted, as +long as this credit is always given. + +### 2. The Launcher is not licensed under the GPL + +The project launcher (the "Launcher") is NOT part of the program licensed +above. It is proprietary: Copyright (C) 2026 BOIS CLUB GAMES, LLC, all rights +reserved. The Launcher may not be copied, modified, redistributed, or used in +any fork, modified version, or other project, in whole or in part. A fork or +derivative must provide its own launcher, which must carry the credit +required by term 1. + +The Launcher consists of the following files, and any later version of them: + +- `src/import/LauncherView.lua` +- `src/import/LauncherSettings.lua` +- `src/import/OnlinePanel.lua` +- `src/import/CartLabelArt.lua` +- `src/import/CartShape.lua` +- `src/mods/LauncherMods.lua` +- the launcher user interface code in `src/import/RomImporter.lua` (the + ROM import, verification, and cache extraction logic in that file remains + under the GPL) +- `assets/launcher/` and `assets/labels/` + +Distributing the Launcher together with this program is permitted only in +unmodified official releases from BOIS CLUB GAMES, LLC. + +### PortMaster distribution permission + +As an exception to the Launcher distribution restriction above, BOIS CLUB +GAMES, LLC grants permission to distribute the Launcher with this program +through PortMaster, including the PortMaster-MV repository and its release +packages. This permission covers PortMaster packaging and launch scripts; +it does not grant a general right to reuse or modify the proprietary Launcher +in other projects. Retain this license and the attribution required above. + +This exception records the explicit PortMaster permission granted by project +owner bryanthaboi on 6 October 2026. All other terms remain unchanged. + +### 3. Names and marks (Section 7(e)) + +This license grants no rights to use the names, logos, or trademarks of BOIS +CLUB GAMES, LLC or of the project, except as needed to give the credit +required by term 1. + +### 4. Third-party rights + +This project contains no ROM data and no extracted game content. Pokemon and +all related names and content are the property of Nintendo, Creatures Inc., +and GAME FREAK inc. This license covers only the original code and assets of +this project. + +--- + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/src/core/DeltaSkin.lua b/src/core/DeltaSkin.lua new file mode 100644 index 000000000..ac5b62e6a --- /dev/null +++ b/src/core/DeltaSkin.lua @@ -0,0 +1,544 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 with Section 7 terms; +-- see licenses/GEN1RECOMP-TOUCH-SKINS.md. Native integration by Roxas2712. +local Json = require("src.link.Json") +local TouchSkin = require("src.core.TouchSkin") + +local DeltaSkin = {} + +DeltaSkin.INFO_NAME = "info.json" +DeltaSkin.MAX_INFO_BYTES = 4 * 1024 * 1024 + +DeltaSkin.GAME_TYPE_PREFIXES = { + "com.rileytestut.delta.game.", + "public.aoshuang.game.", +} + +DeltaSkin.SYSTEMS = { gb = true, gbc = true, gba = true } + +DeltaSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true } + +DeltaSkin.DEVICE_ORDER = { "iphone", "ipad", "tv" } +DeltaSkin.DISPLAY_ORDER = { "edgeToEdge", "standard", "splitView" } +DeltaSkin.ORIENTATIONS = { "portrait", "landscape" } +DeltaSkin.SIDES = { "up", "down", "left", "right" } + +DeltaSkin.ASSET_LADDER = { "small", "medium", "large" } +DeltaSkin.ASSET_WIDTHS = { small = 640, medium = 750, large = 1080 } +DeltaSkin.DEFAULT_TARGET_WIDTH = 1080 + +DeltaSkin.INPUTS = { + a = "a", b = "b", start = "start", select = "select", + up = "up", down = "down", left = "left", right = "right", + l = "l", r = "r", + triggerl = "l", triggerr = "r", + shoulderl = "l", shoulderr = "r", + menu = "menu_toggle", + fastforward = "hold_fast_forward", + togglefastforward = "toggle_fast_forward", +} + +DeltaSkin.OUTPUT_HOTKEYS = { + menu = "menu", + fast_forward_hold = "fastForward", + fast_forward_toggle = "toggleFastForward", +} + +DeltaSkin.MAPPING = { + portrait = { width = 1080, height = 1920 }, + landscape = { width = 1920, height = 1080 }, +} + +DeltaSkin.SCREEN_WIDTH = 160 +DeltaSkin.SCREEN_HEIGHT = 144 + +local function pick(t, key) + if type(t) ~= "table" then return nil end + local direct = t[key] + if direct ~= nil then return direct end + local want = tostring(key):lower() + for k, v in pairs(t) do + if tostring(k):lower() == want then return v end + end + return nil +end + +local function numOr(v, fallback) + local n = tonumber(v) + if not n or n ~= n then return fallback end + return n +end + +local function round(n) + return math.floor(numOr(n, 0) + 0.5) +end + +local function isArray(t) + return type(t) == "table" and t[1] ~= nil +end + +local function addWarning(list, text) + if type(list) ~= "table" then return end + for _, existing in ipairs(list) do + if existing == text then return end + end + list[#list + 1] = text +end + +function DeltaSkin.findInfo(root) + local direct = root .. "/" .. DeltaSkin.INFO_NAME + if TouchSkin.readFile(direct) then return direct, "" end + local items = TouchSkin.listDir(root) + for _, name in ipairs(items) do + if tostring(name):lower() == DeltaSkin.INFO_NAME then + return root .. "/" .. name, "" + end + end + table.sort(items) + for _, name in ipairs(items) do + local nested = root .. "/" .. name .. "/" .. DeltaSkin.INFO_NAME + if TouchSkin.readFile(nested) then return nested, name .. "/" end + end + return nil +end + +function DeltaSkin.resolveName(name, opts) + name = tostring(name or ""):gsub("\\", "/"):gsub("^%./", "") + if name == "" then return nil end + local names = opts and opts.names + if type(names) == "table" then + local want = name:lower() + for _, entry in ipairs(names) do + if tostring(entry):lower() == want then + name = tostring(entry) + break + end + end + end + return ((opts and opts.prefix) or "") .. name +end + +function DeltaSkin.pickAsset(assets, opts, pdfFiles) + if type(assets) ~= "table" then return nil end + pdfFiles = pdfFiles or {} + local raster = {} + local pdfName + for _, key in ipairs(DeltaSkin.ASSET_LADDER) do + local name = pick(assets, key) + if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end + if type(name) == "string" and name ~= "" then + if name:lower():match("%.pdf$") then + pdfName = name + pdfFiles[#pdfFiles + 1] = name + else + raster[#raster + 1] = { key = key, name = name } + end + end + end + local resizable = pick(assets, "resizable") + if type(resizable) == "string" and resizable ~= "" then + if resizable:lower():match("%.pdf$") then + pdfName = resizable + pdfFiles[#pdfFiles + 1] = resizable + else + raster[#raster + 1] = { key = "large", name = resizable } + end + end + local pdfPath = pdfName and DeltaSkin.resolveName(pdfName, opts) or nil + if #raster == 0 then return nil, pdfPath end + + local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH) + local chosen + for _, cand in ipairs(raster) do + if not chosen and (DeltaSkin.ASSET_WIDTHS[cand.key] or 0) >= target then + chosen = cand.name + end + end + if not chosen then chosen = raster[#raster].name end + return DeltaSkin.resolveName(chosen, opts), nil +end + +function DeltaSkin.mergeEdges(base, item) + local out = { top = 0, bottom = 0, left = 0, right = 0 } + for _, side in ipairs({ "top", "bottom", "left", "right" }) do + local v = pick(item, side) + if v == nil then v = pick(base, side) end + out[side] = numOr(v, 0) + end + return out +end + +function DeltaSkin.representation(reps, orient) + for _, device in ipairs(DeltaSkin.DEVICE_ORDER) do + local dev = pick(reps, device) + if type(dev) == "table" then + for _, display in ipairs(DeltaSkin.DISPLAY_ORDER) do + local shown = pick(dev, display) + if type(shown) == "table" then + local obj = pick(shown, orient) + if type(obj) == "table" then return obj, device, display end + end + end + local flat = pick(dev, orient) + if type(flat) == "table" and (pick(flat, "items") or pick(flat, "mappingSize")) then + return flat, device, nil + end + end + end + return nil +end + +function DeltaSkin.directionalInputs(inputs) + if type(inputs) ~= "table" or isArray(inputs) then return nil end + local out, found = {}, 0 + for _, side in ipairs(DeltaSkin.SIDES) do + local v = pick(inputs, side) + if type(v) == "string" then + local lower = v:lower() + local mapped = DeltaSkin.INPUTS[lower] + if not mapped and lower:find(side, 1, true) then mapped = side end + if mapped then + out[side] = mapped + found = found + 1 + end + end + end + if found >= 2 then return out end + return nil +end + +function DeltaSkin.specFor(inputs) + local parts = {} + local function add(v) + if type(v) ~= "string" then return end + local mapped = DeltaSkin.INPUTS[v:lower()] + if mapped then parts[#parts + 1] = mapped end + end + if type(inputs) == "string" then + add(inputs) + elseif type(inputs) == "table" then + if isArray(inputs) then + for _, v in ipairs(inputs) do add(v) end + else + local keys = {} + for k in pairs(inputs) do keys[#keys + 1] = tostring(k) end + table.sort(keys) + for _, k in ipairs(keys) do add(inputs[k]) end + end + end + if #parts == 0 then return "nul" end + return table.concat(parts, "|") +end + +function DeltaSkin.screenRect(obj, mapW, mapH) + local frame + local screens = pick(obj, "screens") + if type(screens) == "table" and type(screens[1]) == "table" then + frame = pick(screens[1], "outputFrame") + end + if type(frame) ~= "table" then frame = pick(obj, "gameScreenFrame") end + if type(frame) ~= "table" then return nil end + local w = numOr(pick(frame, "width"), 0) + local h = numOr(pick(frame, "height"), 0) + if w <= 0 or h <= 0 then return nil end + return { + x = numOr(pick(frame, "x"), 0) / mapW, + y = numOr(pick(frame, "y"), 0) / mapH, + w = w / mapW, h = h / mapH, + } +end + +function DeltaSkin.addItem(page, item, baseEdges, mapW, mapH) + if type(item) ~= "table" then return end + local frame = pick(item, "frame") + if type(frame) ~= "table" then return end + local fw = numOr(pick(frame, "width"), 0) + local fh = numOr(pick(frame, "height"), 0) + if fw <= 0 or fh <= 0 then return end + local fx = numOr(pick(frame, "x"), 0) + local fy = numOr(pick(frame, "y"), 0) + + local edges = DeltaSkin.mergeEdges(baseEdges, pick(item, "extendedEdges")) + local cx, cy = (fx + fw * 0.5) / mapW, (fy + fh * 0.5) / mapH + local w, h = fw / mapW, fh / mapH + local reachLeft = 1 + edges.left / (fw * 0.5) + local reachRight = 1 + edges.right / (fw * 0.5) + local reachUp = 1 + edges.top / (fh * 0.5) + local reachDown = 1 + edges.bottom / (fh * 0.5) + + local inputs = pick(item, "inputs") + local dirs = DeltaSkin.directionalInputs(inputs) + if dirs then + local base = { + x = cx, y = cy, rangeX = w * 0.5, rangeY = h * 0.5, + rangeMod = 1, alphaMod = page.alphaMod, shape = "rect", + reachLeft = reachLeft, reachRight = reachRight, + reachUp = reachUp, reachDown = reachDown, + } + for _, ctl in ipairs(TouchSkin.expandDirectional(base, dirs)) do + page.controls[#page.controls + 1] = ctl + end + return + end + + local shape = tostring(pick(item, "mask") or ""):lower() == "circle" and "radial" or "rect" + local ctl = TouchSkin.newControl(DeltaSkin.specFor(inputs), cx, cy, w, h, shape) + ctl.alphaMod = page.alphaMod + ctl.reachLeft, ctl.reachRight = reachLeft, reachRight + ctl.reachUp, ctl.reachDown = reachUp, reachDown + page.controls[#page.controls + 1] = ctl +end + +function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles) + local mapping = pick(obj, "mappingSize") + local mapW = numOr(pick(mapping, "width"), 0) + local mapH = numOr(pick(mapping, "height"), 0) + if mapW <= 0 or mapH <= 0 then + mapW, mapH = 320, 240 + addWarning(warnings, orient .. " has no mappingSize; assuming 320x240") + end + + local imagePath, pdfPath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles) + local page = { + name = orient, + orient = orient, + imagePath = imagePath, + pdfPath = pdfPath, + fullScreen = true, + normalized = true, + pixelCoords = false, + rangeMod = 1, + alphaMod = pick(obj, "translucent") == true and 0.7 or 1, + aspect = mapW / mapH, + aspectFromCfg = false, + rect = { x = 0, y = 0, w = 1, h = 1 }, + mappingWidth = mapW, + mappingHeight = mapH, + controls = {}, + } + + local screen = DeltaSkin.screenRect(obj, mapW, mapH) + if screen then + page.viewport = screen + page.viewportFill = false + else + -- mappingSize is the overlay, not the device. Portrait controller + -- skins (GBA4iOS-era 320x240 decks, this Pikachu skin, etc.) keep + -- that aspect, sit at the bottom, and leave the leftover for the + -- Game Boy picture. A screens/gameScreenFrame rect still fills. + page.aspectFromCfg = true + page.screenFit = "remainder" + if orient == "portrait" then page.anchor = "bottom" end + end + + local baseEdges = pick(obj, "extendedEdges") + local items = pick(obj, "items") + if type(items) == "table" then + for _, item in ipairs(items) do + DeltaSkin.addItem(page, item, baseEdges, mapW, mapH) + end + end + return page +end + +function DeltaSkin.systemOf(gameType) + if type(gameType) ~= "string" or gameType == "" then return nil end + for _, prefix in ipairs(DeltaSkin.GAME_TYPE_PREFIXES) do + if gameType:sub(1, #prefix) == prefix then + return gameType:sub(#prefix + 1):lower() + end + end + return nil +end + +function DeltaSkin.parse(text, opts) + opts = opts or {} + local info, err = Json.decode(tostring(text or ""), DeltaSkin.MAX_INFO_BYTES) + if type(info) ~= "table" then + return nil, "info.json does not parse: " .. tostring(err) + end + + local gameType = info.gameTypeIdentifier + if type(gameType) ~= "string" or gameType == "" then + return nil, "old GBA4iOS skin, not supported: info.json has no gameTypeIdentifier" + end + if gameType:lower():find("gba4ios", 1, true) then + return nil, "old GBA4iOS skin, not supported" + end + local system = DeltaSkin.systemOf(gameType) + if not system then + return nil, "not a Delta skin: unknown gameTypeIdentifier " .. gameType + end + + local warnings = {} + if not DeltaSkin.SYSTEMS[system] then + addWarning(warnings, "this skin is for " .. system .. ", not Game Boy") + end + + local reps = info.representations + if type(reps) ~= "table" then return nil, "info.json has no representations" end + + local pdfFiles, pages = {}, {} + for _, orient in ipairs(DeltaSkin.ORIENTATIONS) do + local obj = DeltaSkin.representation(reps, orient) + if obj then + local page = DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles) + page.index = #pages + 1 + pages[#pages + 1] = page + end + end + if #pages == 0 then return nil, "info.json has no usable representation" end + + return { + pages = pages, + name = info.name, + author = info.author, + notes = info.notes, + format = "delta", + system = system, + identifier = info.identifier, + warnings = warnings, + pdfFiles = pdfFiles, + } +end + +function DeltaSkin.needsConversion(skin) + if type(skin) ~= "table" then return nil end + local files = skin.pdfFiles + if type(files) ~= "table" or #files == 0 then return nil end + for _, page in ipairs(skin.pages or {}) do + -- A raster asset, or a JPEG recovered from the PDF at load, means the + -- skin can draw. Parse-only callers still see pdfOnly because they + -- have not run extract yet. + if page.rasterData then return nil end + if page.imagePath then return nil end + end + return { pdfOnly = true, files = files } +end + +function DeltaSkin.outputInputs(ctl) + local out = {} + for _, b in ipairs(ctl.buttons or {}) do out[#out + 1] = b end + for _, h in ipairs(ctl.hotkeys or {}) do + local mapped = DeltaSkin.OUTPUT_HOTKEYS[h] + if mapped then out[#out + 1] = mapped end + end + return out +end + +function DeltaSkin.buildRepresentation(page, orient, warnings) + local map = DeltaSkin.MAPPING[orient] or DeltaSkin.MAPPING.portrait + local mapW, mapH = map.width, map.height + local items, files = {}, {} + + for _, ctl in ipairs(page.controls or {}) do + local names = DeltaSkin.outputInputs(ctl) + if ctl.sector and ctl.sector ~= 1 then + names = {} + elseif ctl.sector and ctl.areaNames then + local TouchSkin = require("src.core.TouchSkin") + local dirs = {} + for _, side in ipairs({ "up", "down", "left", "right" }) do + local mapped = TouchSkin.GB_BUTTONS[tostring(ctl.areaNames[side]):lower()] + if mapped then dirs[side] = mapped end + end + names = next(dirs) and dirs or {} + end + if names.up or names.down or names.left or names.right or #names > 0 then + local item = { + inputs = names, + frame = { + x = round((ctl.x - ctl.rangeX) * mapW), + y = round((ctl.y - ctl.rangeY) * mapH), + width = round(ctl.rangeX * 2 * mapW), + height = round(ctl.rangeY * 2 * mapH), + }, + } + if ctl.shape == "radial" then item.mask = "circle" end + local edges, any = {}, false + local pairsList = { + { key = "left", reach = ctl.reachLeft, half = ctl.rangeX * mapW }, + { key = "right", reach = ctl.reachRight, half = ctl.rangeX * mapW }, + { key = "top", reach = ctl.reachUp, half = ctl.rangeY * mapH }, + { key = "bottom", reach = ctl.reachDown, half = ctl.rangeY * mapH }, + } + for _, side in ipairs(pairsList) do + local reach = numOr(side.reach, 1) + if reach ~= 1 then + edges[side.key] = round((reach - 1) * side.half) + any = true + end + end + if any then item.extendedEdges = edges end + items[#items + 1] = item + elseif ctl.imagePath then + addWarning(warnings, "per-button art is dropped: Delta keeps all art in one image") + end + end + + local obj = { + items = items, + mappingSize = { width = mapW, height = mapH }, + extendedEdges = { top = 0, bottom = 0, left = 0, right = 0 }, + translucent = false, + } + if page.imagePath then + obj.assets = { + small = page.imagePath, medium = page.imagePath, large = page.imagePath, + } + files[#files + 1] = page.imagePath + end + if page.viewport then + obj.screens = { { + inputFrame = { x = 0, y = 0, + width = DeltaSkin.SCREEN_WIDTH, height = DeltaSkin.SCREEN_HEIGHT }, + outputFrame = { + x = round(page.viewport.x * mapW), y = round(page.viewport.y * mapH), + width = round(page.viewport.w * mapW), height = round(page.viewport.h * mapH), + }, + } } + end + return obj, files +end + +function DeltaSkin.build(skin, opts) + if type(skin) ~= "table" or not skin.pages or not skin.pages[1] then + return nil, "skin has no pages" + end + opts = opts or {} + local standard, edgeToEdge = {}, {} + local assets, warnings, used = {}, {}, {} + + for _, page in ipairs(skin.pages) do + local orient = TouchSkin.pageOrient(page) + if orient ~= "portrait" and orient ~= "landscape" then + orient = (numOr(page.aspect, 1) < 1) and "portrait" or "landscape" + end + if not used[orient] then + used[orient] = true + local obj, files = DeltaSkin.buildRepresentation(page, orient, warnings) + standard[orient] = obj + edgeToEdge[orient] = obj + for _, rel in ipairs(files) do assets[#assets + 1] = rel end + end + end + + local system = tostring(opts.system or "gbc") + local info = { + name = skin.name or skin.id or "skin", + identifier = opts.identifier + or ("com.gen1recomp.skin." .. tostring(skin.id or "skin")), + gameTypeIdentifier = DeltaSkin.GAME_TYPE_PREFIXES[1] .. system, + debug = false, + representations = { iphone = { standard = standard, edgeToEdge = edgeToEdge } }, + } + return info, assets, warnings +end + +function DeltaSkin.encodeInfo(skin, opts) + local info, assets, warnings = DeltaSkin.build(skin, opts) + if not info then return nil, assets end + return Json.encode(info), assets, warnings +end + +return DeltaSkin diff --git a/src/core/Game.lua b/src/core/Game.lua index ef00d1af3..592914777 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -86,6 +86,9 @@ function Game:load() self.touchControls = TouchControls TouchControls:init() + TouchControls:setHotkeyHandler(function(action, pressed) + self:touchSkinHotkey(action, pressed) + end) self.renderer = Renderer Renderer:init() @@ -310,6 +313,36 @@ function Game:returnToTitle() self.stack:push(self:makeTitleState()) end +-- Skin actions use the same native speed/title/options owners as keyboard input. +Game.SKIN_FAST_FORWARD = 4 +function Game:touchSkinHotkey(action, pressed) + if action == "fast_forward_hold" then + if pressed then + self.skinSpeedSaved = self.speedOverride + self.speedOverride = Game.SKIN_FAST_FORWARD + else + self.speedOverride = self.skinSpeedSaved + self.skinSpeedSaved = nil + end + elseif action == "fast_forward_toggle" and pressed then + if self:speedLocked() or not (self.save and self.save.options) then return end + local Speed = require("src.core.GameSpeed") + self.save.options.speed = Speed.cycle(self.save.options.speed, 1) + self.speedOverride = nil + self:writeOptions() + elseif action == "soft_reset" and pressed then + Input:reset() + TouchControls:reset() + self:returnToTitle() + elseif action == "menu" and pressed then + local top = self.stack and self.stack:top() + if top and not top.isTouchSkinMenu then + local state = require("src.ui.Screens").build(self, "OptionsMenu") + if state then state.isTouchSkinMenu = true; self.stack:push(state) end + end + end +end + function Game:step(dt) -- THE WHOLE LOGIC STEP, because `stack:update` alone accounted for under -- 2 ms of a 43 ms update and everything else in here was invisible. Run 4 @@ -1776,8 +1809,7 @@ function Game:touchpressed(id, x, y, dx, dy, pressure) -- one opens a different screen before the finger is lifted. if not self.secondScreenInjecting and TouchControls.visible and TouchControls:visible() and TouchControls:hitTest(x,y) then - TouchControls:touchpressed(id,x,y) - return + if TouchControls:touchpressed(id,x,y) then return end end if modPointer(self, "pressed", id, x, y, dx, dy, pressure) then return end if offerPointer(self, "touchpressed", id, x, y) then return end diff --git a/src/core/PdfImage.lua b/src/core/PdfImage.lua new file mode 100644 index 000000000..671e94bcc --- /dev/null +++ b/src/core/PdfImage.lua @@ -0,0 +1,118 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 with Section 7 terms; +-- see licenses/GEN1RECOMP-TOUCH-SKINS.md. Native integration by Roxas2712. +-- Recover a raster from a PDF that is really a wrapped JPEG. Delta skins +-- ship artwork that way so iOS can scale it; LOVE has no PDF renderer, so +-- import pulls the embedded image out instead of refusing the skin. True +-- vector PDFs (no Image XObject, no JPEG) still fail. + +local PdfImage = {} + +local function isPdf(bytes) + return type(bytes) == "string" and bytes:sub(1, 5) == "%PDF-" +end + +-- After the `stream` keyword the spec allows \n or \r\n before the bytes. +-- `endstream` also contains the letters "stream", so skip that match. +local function streamDataStart(bytes, from) + local s, e = bytes:find("stream", from, true) + while s do + if s == 1 or bytes:sub(s - 3, s - 1) ~= "end" then + local p = e + 1 + if bytes:sub(p, p) == "\r" then p = p + 1 end + if bytes:sub(p, p) == "\n" then p = p + 1 end + return p, s + end + s, e = bytes:find("stream", e + 1, true) + end + return nil +end + +local function dictWindow(bytes, imageAt) + local from = imageAt > 400 and (imageAt - 400) or 1 + local to = math.min(#bytes, imageAt + 800) + return bytes:sub(from, to) +end + +local function dictNumber(window, key) + -- Prefer an indirect ref so `/Length 5 0 R` is not read as length 5. + if window:find("/" .. key .. "%s+%d+%s+%d+%s+R") then return nil end + return tonumber(window:match("/" .. key .. "%s+(%d+)")) +end + +local function dictFilter(window) + local named = window:match("/Filter%s*/(%w+)") + if named then return named end + return window:match("/Filter%s*%[%s*/(%w+)") +end + +local function jpegIn(bytes, from, to) + if from < 1 then from = 1 end + if not to or to > #bytes then to = #bytes end + if to < from then return nil end + local region = bytes:sub(from, to) + local soi = region:find("\255\216\255", 1, true) + if not soi then return nil end + local eoi = region:find("\255\217", soi + 3, true) + if not eoi then return nil end + return region:sub(soi, eoi + 1) +end + +local function candidate(data, width, height, ext) + if not data or data == "" then return nil end + return { + data = data, + ext = ext or "jpg", + width = width or 0, + height = height or 0, + } +end + +local function bigger(a, b) + if not a then return b end + if not b then return a end + local as = (a.width or 0) * (a.height or 0) + local bs = (b.width or 0) * (b.height or 0) + if bs ~= as then return bs > as and b or a end + return #b.data > #a.data and b or a +end + +-- Walk Image XObjects and take the largest DCTDecode (JPEG) stream. +local function fromImageXObjects(bytes) + local best + local i = 1 + while true do + local s, e = bytes:find("/Subtype%s*/Image", i) + if not s then break end + local window = dictWindow(bytes, s) + local filter = dictFilter(window) + local width = dictNumber(window, "Width") + local height = dictNumber(window, "Height") + local dataStart = streamDataStart(bytes, e) + i = e + 1 + if dataStart and filter == "DCTDecode" then + local es = bytes:find("endstream", dataStart, true) + local jpeg = jpegIn(bytes, dataStart, es and (es - 1) or nil) + best = bigger(best, candidate(jpeg, width, height, "jpg")) + end + end + return best +end + +-- Image-to-PDF converters (3-Heights, Preview, etc.) leave a single JPEG +-- body even when /Length is an indirect object we do not resolve. +local function fromBareJpeg(bytes) + local jpeg = jpegIn(bytes, 1, #bytes) + if not jpeg then return nil end + return candidate(jpeg, 0, 0, "jpg") +end + +function PdfImage.extract(bytes) + if not isPdf(bytes) then return nil, "not a pdf" end + local best = fromImageXObjects(bytes) + if not best then best = fromBareJpeg(bytes) end + if not best then return nil, "no extractable image" end + return best +end + +return PdfImage diff --git a/src/core/SkinZip.lua b/src/core/SkinZip.lua new file mode 100644 index 000000000..acc6cf287 --- /dev/null +++ b/src/core/SkinZip.lua @@ -0,0 +1,95 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 with Section 7 terms; +-- see licenses/GEN1RECOMP-TOUCH-SKINS.md. Native integration by Roxas2712. +local SkinZip = {} + +local bxor +do + local ok, bit = pcall(require, "bit") + if ok and bit and bit.bxor then + bxor = function(a, b) return bit.bxor(a, b) % 0x100000000 end + else + local byteXor = {} + local function xor8(a, b) + local key = a * 256 + b + local memo = byteXor[key] + if memo then return memo end + local r, bitv = 0, 1 + local x, y = a, b + for _ = 1, 8 do + if (x % 2) ~= (y % 2) then r = r + bitv end + x, y, bitv = math.floor(x / 2), math.floor(y / 2), bitv * 2 + end + byteXor[key] = r + return r + end + bxor = function(a, b) + local r, mul = 0, 1 + for _ = 1, 4 do + r = r + xor8(a % 256, b % 256) * mul + a, b, mul = math.floor(a / 256), math.floor(b / 256), mul * 256 + end + return r + end + end +end + +local crcTable +local function crc32(s) + if not crcTable then + crcTable = {} + for i = 0, 255 do + local c = i + for _ = 1, 8 do + if c % 2 == 1 then + c = bxor(math.floor(c / 2), 0xEDB88320) + else + c = math.floor(c / 2) + end + end + crcTable[i] = c + end + end + local crc = 0xFFFFFFFF + for i = 1, #s do + crc = bxor(crcTable[bxor(crc, s:byte(i)) % 256], math.floor(crc / 256)) + end + return bxor(crc, 0xFFFFFFFF) % 0x100000000 +end + +local function le16(n) + n = math.floor(n) % 0x10000 + return string.char(n % 256, math.floor(n / 256) % 256) +end + +local function le32(n) + n = math.floor(n) % 0x100000000 + return string.char(n % 256, math.floor(n / 256) % 256, + math.floor(n / 65536) % 256, math.floor(n / 16777216) % 256) +end + +function SkinZip.encode(entries) + local out, central, offset = {}, {}, 0 + for _, entry in ipairs(entries) do + local name, data = entry.name, entry.data or "" + local crc, size = crc32(data), #data + local local_ = "PK\3\4" .. le16(20) .. le16(0) .. le16(0) + .. le16(0) .. le16(0) + .. le32(crc) .. le32(size) .. le32(size) + .. le16(#name) .. le16(0) .. name + out[#out + 1] = local_ + out[#out + 1] = data + central[#central + 1] = "PK\1\2" .. le16(20) .. le16(20) .. le16(0) .. le16(0) + .. le16(0) .. le16(0) + .. le32(crc) .. le32(size) .. le32(size) + .. le16(#name) .. le16(0) .. le16(0) .. le16(0) .. le16(0) + .. le32(0) .. le32(offset) .. name + offset = offset + #local_ + #data + end + local dir = table.concat(central) + return table.concat(out) .. dir + .. "PK\5\6" .. le16(0) .. le16(0) .. le16(#entries) .. le16(#entries) + .. le32(#dir) .. le32(offset) .. le16(0) +end + +return SkinZip diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 8727bd055..7f66ea929 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -30,6 +30,8 @@ local Input = require("src.core.Input") local SafeArea = require("src.core.SafeArea") local GameVersion = require("src.core.GameVersion") +local TouchSkin = require("src.core.TouchSkin") + local TouchControls = {} -- idle vs pressed overlay opacity @@ -152,6 +154,7 @@ function TouchControls.normalizeConfig(tc) -- no caller ever has to nil-check a bucket's scale if type(tc) ~= "table" then tc = {} end if tc.enabled == false then out.enabled = false end + if type(tc.skin) == "string" and tc.skin ~= "" then out.skin = tc.skin end local saved = type(tc.layouts) == "table" and tc.layouts or nil for _, o in ipairs(ORIENTATIONS) do local b = saved and saved[o] @@ -226,6 +229,10 @@ end function TouchControls:init() self.active = wantsOverlay() + TouchSkin.setOverlayLive(self.active) + TouchSkin.setActive(nil) + self.skinId, self.skinError = nil, nil + self.hotkeysHeld, self.keysHeld = {}, {} self.enabled = true -- per-orientation buckets (#633); self.positions / self.scale mirror the -- one currently on screen so layout(), the editor and the tests keep a @@ -267,6 +274,8 @@ end function TouchControls:applyOptions(opts) local cfg = TouchControls.normalizeConfig(opts and opts.touchControls) self.enabled = cfg.enabled + TouchSkin.setOverlayLive(self.active) + self:selectSkin(cfg.enabled and cfg.skin or nil) self.layouts = cfg.layouts self.layoutW, self.layoutH = nil, nil self.layoutOx, self.layoutOy = nil, nil @@ -282,7 +291,7 @@ end -- Snapshot for the editor's save path: enabled plus both orientation -- buckets, matching what options.lua stores (#633). function TouchControls:config() - local out = { enabled = self.enabled ~= false, layouts = {} } + local out = { enabled = self.enabled ~= false, skin = self.skinId, layouts = {} } for _, o in ipairs(ORIENTATIONS) do local b = self.layouts and self.layouts[o] or nil out.layouts[o] = { @@ -305,11 +314,59 @@ function TouchControls:setPreview(on) end function TouchControls:visible() - if self.preview then return self.img ~= nil end - return self.active and self.enabled ~= false and self.img ~= nil - and not self.controllerHidden + local art = TouchSkin.active ~= nil or self.img ~= nil + if self.preview then return art end + if self.enabled == false or not art then return false end + -- A selected skin is also a desktop/TV bezel. Input remains gated in + -- touchpressed, but the artwork must not disappear when a controller is + -- connected or the platform is not touch-first. + if TouchSkin.active then return true end + return self.active and not self.controllerHidden +end + +local function surfaceRect() + local r = TouchSkin.surfaceRect + if r then return r.w, r.h, r.x, r.y end + if love and love.graphics and love.graphics.getDimensions then + local w, h = love.graphics.getDimensions() + return w, h, 0, 0 + end + return 0, 0, 0, 0 +end + +TouchControls.surfaceRect = surfaceRect + +function TouchControls:selectSkin(id) + if id == self.skinId and TouchSkin.active then return TouchSkin.active end + self:reset() + self.skinError = nil + if not id or id == "" then + self.skinId = nil + TouchSkin.setActive(nil) + return nil + end + local skin, err = TouchSkin.select(id) + if not skin then + self.skinId = nil + self.skinError = err + TouchSkin.setActive(nil) + return nil, err + end + self.skinId = id + return skin end +function TouchControls:skin() + if not self:visible() then return nil end + return TouchSkin.active +end + +function TouchControls:setHotkeyHandler(fn) + self.hotkeyHandler = type(fn) == "function" and fn or nil +end + +local skinHitSet, applySkinSet + -- Keep a control fully inside the usable rect [x0,y0]..[x1,y1]. local function clampZone(zone, x0, y0, x1, y1) local half = zone.w * 0.5 @@ -437,6 +494,17 @@ end -- Which control (if any) contains (x, y). Prefer face buttons over the -- d-pad when they overlap, matching touchpressed's order. function TouchControls:hitTest(x, y) + local page = TouchSkin.page() + if page then + local ww, wh, ox, oy = surfaceRect() + for i = #page.controls, 1, -1 do + local ctl = page.controls[i] + if not ctl.decorative and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then + return ctl.spec, ctl + end + end + return nil + end local L = self:layout() for _, btn in ipairs(BUTTONS) do if buttonAvailable(self,btn) and L[btn] and inCircle(L[btn], x, y, SLOP[btn]) then return btn end @@ -484,16 +552,109 @@ local function setDpad(self, touch, dir) if dir then pressBtn(self, dir) end end +-- Custom-skin control ownership adapted from Gen1Recomp 535a5ec0. +-- Copyright (C) 2026 BOIS CLUB GAMES, LLC; licenses/GEN1RECOMP-TOUCH-SKINS.md. +local function pressKey(self, key, down) + self.keysHeld = self.keysHeld or {} + local before = self.keysHeld[key] or 0 + local count = math.max(0, before + (down and 1 or -1)) + self.keysHeld[key] = count > 0 and count or nil + if down and before > 0 or not down and (before == 0 or count > 0) then return end + local fn = down and love.keypressed or love.keyreleased + if type(fn) == "function" then pcall(fn, key, key, false) end +end + +local function fireHotkey(self, action, pressed, ctl) + if action == "overlay_next" then + if pressed then TouchSkin.nextPage(ctl and ctl.nextTarget) end + return pressed + end + if action == "overlay_prev" then + if pressed then TouchSkin.setPage(TouchSkin.pageIndex - 1) end + return pressed + end + self.hotkeysHeld = self.hotkeysHeld or {} + local n = (self.hotkeysHeld[action] or 0) + (pressed and 1 or -1) + if n < 0 then n = 0 end + self.hotkeysHeld[action] = n > 0 and n or nil + local edge = (pressed and n == 1) or (not pressed and n == 0) + if edge and self.hotkeyHandler then + pcall(self.hotkeyHandler, action, pressed) + end + return false +end + +local function enterControl(self, ctl) + for _, btn in ipairs(ctl.buttons) do + pressBtn(self, btn) + end + for _, key in ipairs(ctl.keys) do pressKey(self, key, true) end + local switched = false + for _, action in ipairs(ctl.hotkeys) do + if fireHotkey(self, action, true, ctl) then switched = true end + end + return switched +end + +local function exitControl(self, ctl) + for _, btn in ipairs(ctl.buttons) do releaseBtn(self, btn) end + for _, key in ipairs(ctl.keys) do pressKey(self, key, false) end + for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end +end + +function skinHitSet(self, x, y, prev) + local page = TouchSkin.page() + if not page then return nil end + local ww, wh, ox, oy = surfaceRect() + local set = nil + for _, ctl in ipairs(page.controls) do + local held = (prev and prev[ctl]) == true + if not ctl.decorative + and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy, held) then + set = set or {} + set[ctl] = true + end + end + return set +end + +function applySkinSet(self, touch, set) + local prev = touch.set or {} + local switched = false + for ctl in pairs(prev) do + if not (set and set[ctl]) then exitControl(self, ctl) end + end + for ctl in pairs(set or {}) do + if not prev[ctl] then + if enterControl(self, ctl) then switched = true end + end + end + touch.set = set + if switched then + for ctl in pairs(touch.set or {}) do exitControl(self, ctl) end + touch.set = nil + end +end + + function TouchControls:touchpressed(id, x, y) -- preview mode is layout-edit only: never press GB buttons if self.preview then return end - if not (self.active and self.enabled ~= false and self.img) then return end + if not (self.active and self.enabled ~= false and (TouchSkin.active or self.img)) then return end -- A reused native pointer id must release its previous hold first. if self.touches[id] then self:touchreleased(id,x,y) end -- A controller hid the overlay; restore it and accept this same press. if self.controllerHidden then self.controllerHidden = false end + if TouchSkin.active then + local set = skinHitSet(self, x, y, nil) + if not set then return end + local touch = { control = "skin" } + self.touches[id] = touch + applySkinSet(self, touch, set) + return true + end local L = self:layout() for _, btn in ipairs(BUTTONS) do if buttonAvailable(self,btn) and L[btn] and inCircle(L[btn], x, y, SLOP[btn]) then @@ -528,6 +689,10 @@ function TouchControls:touchmoved(id, x, y) if self.preview then return end local touch = self.touches[id] if not touch then return end + if touch.control == "skin" then + applySkinSet(self, touch, skinHitSet(self, x, y, touch.set)) + return + end -- Gen4 free camera look if touch.control == "cameralook" and self.cameraLookStart then @@ -560,7 +725,9 @@ function TouchControls:touchreleased(id, x, y) if not touch then return end self.touches[id] = nil - if touch.control == "cameralook" then + if touch.control == "skin" then + applySkinSet(self, touch, nil) + elseif touch.control == "cameralook" then self.cameraLookTouch = nil self.cameraLookStart = nil elseif touch.control == "dpad" then @@ -576,6 +743,17 @@ end -- touchreleased and would strand its button held forever. Called from -- Game alongside Input:reset() on focus/visibility loss. function TouchControls:reset() + for _, touch in pairs(self.touches or {}) do + if touch.control == "skin" then applySkinSet(self, touch, nil) end + end + for key in pairs(self.keysHeld or {}) do + self.keysHeld[key] = 1 + pressKey(self, key, false) + end + for action in pairs(self.hotkeysHeld or {}) do + if self.hotkeyHandler then pcall(self.hotkeyHandler, action, false) end + end + self.keysHeld, self.hotkeysHeld = {}, {} for btn in pairs(self.held or {}) do Input:overlayReleased(btn) end @@ -637,8 +815,53 @@ end -- Screen-space, called by Game:draw after Renderer:endFrame so the -- overlay rides on top of everything (world, UI, CRT/GBC FX included). -- Also used by the launcher layout editor under preview mode. +local function drawOverlayImage(img, x, y, w, h, alpha) + if not img or alpha <= 0 then return end + local sx, sy = TouchSkin.imageFit(img:getWidth(), img:getHeight(), w, h) + if not sx then return end + love.graphics.setColor(1, 1, 1, math.min(1, alpha)) + love.graphics.draw(img, x, y, 0, sx, sy) +end + +function TouchControls:drawSkin(alphaMul) + local page = TouchSkin.page() + if not page then return false end + local ww, wh, sox, soy = surfaceRect() + local bx, by, bw, bh = TouchSkin.pageBox(page, ww, wh, sox, soy) + local opacity = (self.skinOpacity or 1) * alphaMul + + love.graphics.push("all") + love.graphics.origin() + drawOverlayImage(page.image, bx, by, bw, bh, opacity) + + local pressed = {} + for _, touch in pairs(self.touches or {}) do + for ctl in pairs(touch.set or {}) do pressed[ctl] = true end + end + + for _, ctl in ipairs(page.controls) do + local down = pressed[ctl] == true + local img = (down and ctl.pressedImage) or ctl.image + if img then + local cx, cy, halfW, halfH = + TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy) + local alpha = opacity + if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end + drawOverlayImage(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha) + end + end + + love.graphics.pop() + return true +end + + function TouchControls:draw() if not self:visible() then return end + if TouchSkin.active then + local mul = (self.preview and self.enabled == false) and 0.45 or 1 + if self:drawSkin(mul) then return end + end local L = self:layout() -- when the player disabled the overlay but the editor is previewing, -- draw dimmed so the layout is still editable diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua new file mode 100644 index 000000000..c0ff3f545 --- /dev/null +++ b/src/core/TouchSkin.lua @@ -0,0 +1,1496 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 with Section 7 terms; +-- see licenses/GEN1RECOMP-TOUCH-SKINS.md. Native integration by Roxas2712. +local TouchSkin = {} + +TouchSkin.BUNDLED_ROOT = "assets/skins" +TouchSkin.USER_ROOT = "skins" +TouchSkin.EXPORT_ROOT = "skins/_export" + +TouchSkin.GB_BUTTONS = { + a = "a", b = "b", x = "x", y = "y", start = "start", select = "select", + up = "up", down = "down", left = "left", right = "right", + l = "l", r = "r", + l1 = "l", r1 = "r", + l2 = "l", r2 = "r", + trigger_l = "l", trigger_r = "r", + shoulder_l = "l", shoulder_r = "r", +} + +TouchSkin.HOTKEYS = { + overlay_next = "overlay_next", + overlay_previous = "overlay_prev", + menu_toggle = "menu", + reset = "soft_reset", + hold_fast_forward = "fast_forward_hold", + fast_forward = "fast_forward_hold", + toggle_fast_forward = "fast_forward_toggle", + screenshot = "screenshot", + pause_toggle = "pause", + exit_emulator = "quit", +} + +local DEFAULT_ASPECT = 16 / 9 +local PORTRAIT_ASPECT = 9 / 16 + +local function trim(s) + return (tostring(s or ""):match("^%s*(.-)%s*$")) +end + +local function unquote(s) + s = trim(s) + local quoted = s:match('^"([^"]*)"') + if quoted then return quoted end + s = s:gsub("%s*//.*$", ""):gsub("%s*#.*$", "") + s = trim(s) + local inner = s:match('^"(.*)"$') + return inner or s +end + +local function toBool(v) + v = trim(v):lower() + return v == "true" or v == "1" +end + +local function tokens(s) + local out = {} + for tok in tostring(s or ""):gmatch("[^,%s]+") do out[#out + 1] = tok end + return out +end + +local function num(v, fallback) + local n = tonumber(trim(v)) + if not n or n ~= n then return fallback end + return n +end + +function TouchSkin.parseConfig(text) + local kv = {} + for line in tostring(text or ""):gmatch("[^\r\n]+") do + if not line:match("^%s*#") then + local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$") + if key then kv[key] = unquote(value) end + end + end + return kv +end + +local function parseBinds(spec) + local buttons, hotkeys, keys, decorative = {}, {}, {}, true + for raw in tostring(spec or ""):gmatch("[^|]+") do + local name = trim(raw):lower() + local key = name:match("^key:(.+)$") or name:match("^retrok_(.+)$") + if name == "nul" or name == "" then + -- decoration + elseif key then + keys[#keys + 1] = key + decorative = false + elseif TouchSkin.GB_BUTTONS[name] then + buttons[#buttons + 1] = TouchSkin.GB_BUTTONS[name] + decorative = false + elseif TouchSkin.HOTKEYS[name] then + hotkeys[#hotkeys + 1] = TouchSkin.HOTKEYS[name] + decorative = false + end + end + return buttons, hotkeys, keys, decorative +end + +TouchSkin.AREA_DEFAULTS = { + dpad_area = { up = "up", down = "down", left = "left", right = "right" }, + abxy_area = { up = "x", down = "b", left = "y", right = "a" }, + analog_left = { up = "up", down = "down", left = "left", right = "right" }, + analog_right = { up = "up", down = "down", left = "left", right = "right" }, +} + +local DIRECTIONAL_CELLS = { + { col = 1, row = 1, h = "left", v = "up" }, + { col = 2, row = 1, v = "up" }, + { col = 3, row = 1, h = "right", v = "up" }, + { col = 1, row = 2, h = "left" }, + { col = 3, row = 2, h = "right" }, + { col = 1, row = 3, h = "left", v = "down" }, + { col = 2, row = 3, v = "down" }, + { col = 3, row = 3, h = "right", v = "down" }, +} + +local function outwardReach(reach) + return 1 + 3 * ((num(reach, 1)) - 1) +end + +function TouchSkin.expandDirectional(base, names) + names = names or {} + local cellX = math.abs(num(base.rangeX, 0.05)) / 3 + local cellY = math.abs(num(base.rangeY, 0.05)) / 3 + local out = {} + for _, cell in ipairs(DIRECTIONAL_CELLS) do + local parts = {} + if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end + if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end + local spec = #parts > 0 and table.concat(parts, "|") or "nul" + local ctl = TouchSkin.newControl(spec, + num(base.x, 0.5) + (cell.col - 2) * cellX * 2, + num(base.y, 0.5) + (cell.row - 2) * cellY * 2, + cellX * 2, cellY * 2, "rect") + ctl.rangeMod = num(base.rangeMod, 1) + ctl.alphaMod = num(base.alphaMod, 1) + ctl.reachLeft = cell.col == 1 and outwardReach(base.reachLeft) or 1 + ctl.reachRight = cell.col == 3 and outwardReach(base.reachRight) or 1 + ctl.reachUp = cell.row == 1 and outwardReach(base.reachUp) or 1 + ctl.reachDown = cell.row == 3 and outwardReach(base.reachDown) or 1 + ctl.pixelCoords = base.pixelCoords + ctl.movable = base.movable + ctl.exclusive = base.exclusive + out[#out + 1] = ctl + end + return out +end + +local SECTOR_CELLS = { + { h = "right" }, + { h = "right", v = "down" }, + { v = "down" }, + { h = "left", v = "down" }, + { h = "left" }, + { h = "left", v = "up" }, + { v = "up" }, + { h = "right", v = "up" }, +} + +TouchSkin.SECTOR_SPAN = math.pi / 4 + +function TouchSkin.sectorHit(sector, dx, dy) + local span = TouchSkin.SECTOR_SPAN + local start = (sector - 1) * span - span * 0.5 + local a = (math.atan2(dy, dx) - start) % (math.pi * 2) + return a < span +end + +function TouchSkin.expandSectors(base, names) + names = names or {} + local out = {} + for i, cell in ipairs(SECTOR_CELLS) do + local parts = {} + if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end + if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end + local spec = #parts > 0 and table.concat(parts, "|") or "nul" + local ctl = TouchSkin.newControl(spec, num(base.x, 0.5), num(base.y, 0.5), + math.abs(num(base.rangeX, 0.05)) * 2, math.abs(num(base.rangeY, 0.05)) * 2, + base.shape) + ctl.sector = i + ctl.areaKind = base.areaKind + ctl.areaNames = base.areaNames + ctl.rangeMod = num(base.rangeMod, 1) + ctl.alphaMod = num(base.alphaMod, 1) + ctl.reachLeft = num(base.reachLeft, 1) + ctl.reachRight = num(base.reachRight, 1) + ctl.reachUp = num(base.reachUp, 1) + ctl.reachDown = num(base.reachDown, 1) + ctl.pixelCoords = base.pixelCoords + ctl.movable = base.movable + ctl.exclusive = base.exclusive + out[#out + 1] = ctl + end + return out +end + +local function areaSide(kv, prefix, side, fallback) + local v = kv[prefix .. "_" .. side] + if v == nil or trim(v) == "" then return fallback end + return trim(v) +end + +local function parseDesc(kv, prefix, page) + local spec = kv[prefix] + if not spec then return nil end + local t = tokens(spec) + if #t < 6 then return nil end + + local shape = trim(t[4]):lower() + if shape ~= "radial" and shape ~= "rect" then shape = "rect" end + + local buttons, hotkeys, keys, decorative = parseBinds(t[1]) + local reachX = num(kv[prefix .. "_reach_x"], 1) + local reachY = num(kv[prefix .. "_reach_y"], 1) + + local ctl = { + spec = t[1], + buttons = buttons, + hotkeys = hotkeys, + keys = keys, + decorative = decorative, + x = num(t[2], 0.5), + y = num(t[3], 0.5), + shape = shape, + rangeX = math.abs(num(t[5], 0.05)), + rangeY = math.abs(num(t[6], 0.05)), + rangeMod = num(kv[prefix .. "_range_mod"], page.rangeMod), + alphaMod = num(kv[prefix .. "_alpha_mod"], page.alphaMod), + reachUp = num(kv[prefix .. "_reach_up"], reachY), + reachDown = num(kv[prefix .. "_reach_down"], reachY), + reachLeft = num(kv[prefix .. "_reach_left"], reachX), + reachRight = num(kv[prefix .. "_reach_right"], reachX), + imagePath = kv[prefix .. "_overlay"], + pressedImagePath = kv[prefix .. "_overlay_pressed"], + nextTarget = kv[prefix .. "_next_target"], + movable = toBool(kv[prefix .. "_movable"]) or nil, + exclusive = (toBool(kv[prefix .. "_exclusive"]) + or toBool(kv[prefix .. "_range_mod_exclusive"])) or nil, + saturatePct = num(kv[prefix .. "_saturate_pct"], nil), + } + if ctl.imagePath == "" then ctl.imagePath = nil end + if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end + + local normalized = kv[prefix .. "_normalized"] + if normalized ~= nil then ctl.pixelCoords = not toBool(normalized) end + + local areaKind = trim(t[1]):lower() + local defaults = TouchSkin.AREA_DEFAULTS[areaKind] + if defaults then + ctl.areaKind = areaKind + ctl.areaNames = { + up = areaSide(kv, prefix, "up", defaults.up), + down = areaSide(kv, prefix, "down", defaults.down), + left = areaSide(kv, prefix, "left", defaults.left), + right = areaSide(kv, prefix, "right", defaults.right), + } + end + return ctl +end + +function TouchSkin.parse(text) + local kv = TouchSkin.parseConfig(text) + local count = math.floor(num(kv.overlays, 0)) + if count <= 0 then return nil, "no overlays" end + + local pages, warnings = {}, {} + local function warn(text) + for _, existing in ipairs(warnings) do + if existing == text then return end + end + warnings[#warnings + 1] = text + end + + for i = 0, count - 1 do + local p = "overlay" .. i + local page = { + index = i + 1, + name = kv[p .. "_name"] or ("overlay" .. i), + imagePath = kv[p .. "_overlay"], + fullScreen = toBool(kv[p .. "_full_screen"]), + normalized = toBool(kv[p .. "_normalized"]), + rangeMod = num(kv[p .. "_range_mod"], 1), + alphaMod = num(kv[p .. "_alpha_mod"], 1), + aspect = num(kv[p .. "_aspect_ratio"], nil), + controls = {}, + } + if page.imagePath == "" then page.imagePath = nil end + -- An explicit aspect_ratio is the overlay's design aspect. RetroArch + -- letterboxes to it even when full_screen is set, so range_x/range_y + -- that were authored as a circle stay a circle. #1503 + page.aspectFromCfg = page.aspect ~= nil and page.aspect > 0 + if not page.aspectFromCfg then + page.aspect = page.name:lower():find("portrait", 1, true) + and PORTRAIT_ASPECT or DEFAULT_ASPECT + end + + local rect = tokens(kv[p .. "_rect"]) + page.rect = { x = 0, y = 0, w = 1, h = 1 } + if #rect >= 4 then + page.rect = { x = num(rect[1], 0), y = num(rect[2], 0), + w = num(rect[3], 1), h = num(rect[4], 1) } + end + + local vp = tokens(kv[p .. "_viewport"]) + if #vp >= 4 then + page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0), + w = num(vp[3], 1), h = num(vp[4], 1) } + page.viewportFill = toBool(kv[p .. "_viewport_fill"]) + end + + page.pixelCoords = not page.normalized + if page.pixelCoords and not page.imagePath then + page.pixelCoords = false + warn(page.name .. " has no base image: desc coordinates read as normalized") + end + + local descs = math.floor(num(kv[p .. "_descs"], 0)) + for d = 0, descs - 1 do + local ctl = parseDesc(kv, p .. "_desc" .. d, page) + if not ctl then + warn(page.name .. " is missing desc " .. d) + elseif ctl.areaKind then + if ctl.imagePath or ctl.pressedImagePath then + local art = TouchSkin.newControl("nul", ctl.x, ctl.y, + ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape) + art.imagePath = ctl.imagePath + art.pressedImagePath = ctl.pressedImagePath + art.rangeMod, art.alphaMod = ctl.rangeMod, ctl.alphaMod + art.pixelCoords = ctl.pixelCoords + art.movable, art.exclusive = ctl.movable, ctl.exclusive + page.controls[#page.controls + 1] = art + end + for _, cell in ipairs(TouchSkin.expandSectors(ctl, ctl.areaNames)) do + page.controls[#page.controls + 1] = cell + end + else + page.controls[#page.controls + 1] = ctl + end + end + pages[#pages + 1] = page + end + + -- RetroArch auto-rotate: overlay names containing portrait / landscape + -- are the lock. Stamp it so the studio does not need a second click. #1503 + for _, page in ipairs(pages) do + if not page.orient then page.orient = TouchSkin.pageOrient(page) end + end + + return { pages = pages, warnings = warnings } +end + +local function readFile(path) + if love and love.filesystem and love.filesystem.read then + local ok, data = pcall(love.filesystem.read, path) + if ok and data then return data end + end + return require("src.core.FilePicker").read(path) +end + +local function listDir(path) + if love and love.filesystem and love.filesystem.getDirectoryItems then + local ok, items = pcall(love.filesystem.getDirectoryItems, path) + if ok and items then return items end + end + return {} +end + +local function wfs() + return require("src.core.SaveData").persistenceFs(love.filesystem) +end + +local function isDir(path) + if love and love.filesystem and love.filesystem.getInfo then + local ok, info = pcall(love.filesystem.getInfo, path) + if ok and info then return info.type == "directory" end + end + return false +end + +local function joinPath(root, rel) + rel = tostring(rel or ""):gsub("^%./", ""):gsub("\\", "/") + if rel:sub(1, 1) == "/" then return rel:sub(2) end + return root .. "/" .. rel +end + +TouchSkin.NATIVE_NAME = "skin.lua" + +TouchSkin.readFile = readFile +TouchSkin.listDir = listDir +TouchSkin.isDir = isDir + +local function findConfig(root) + if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then + return root .. "/" .. TouchSkin.NATIVE_NAME, "native", "" + end + local named = { "overlay.cfg", "skin.cfg", "layout.cfg" } + for _, name in ipairs(named) do + if readFile(root .. "/" .. name) then + return root .. "/" .. name, "retroarch", "" + end + end + local infoPath, prefix = require("src.core.DeltaSkin").findInfo(root) + if infoPath then return infoPath, "delta", prefix end + local items = listDir(root) + table.sort(items) + for _, name in ipairs(items) do + if name:match("%.cfg$") then return root .. "/" .. name, "retroarch", "" end + end + return nil +end + +local function loadDataChunk(text, name) + if loadstring then + local chunk, err = loadstring(text, name) + if not chunk then return nil, err end + return setfenv(chunk, {}) + end + return load(text, name, "t", {}) +end + +function TouchSkin.parseNative(text) + local chunk, err = loadDataChunk(tostring(text or ""), "skin") + if not chunk then return nil, "skin.lua does not parse: " .. tostring(err) end + local ok, data = pcall(chunk) + if not ok or type(data) ~= "table" then return nil, "skin.lua returned no table" end + if type(data.pages) ~= "table" or not data.pages[1] then + return nil, "skin.lua has no pages" + end + + local pages = {} + for i, raw in ipairs(data.pages) do + if type(raw) ~= "table" then return nil, "page " .. i .. " is not a table" end + local page = { + index = i, + name = tostring(raw.name or ("page" .. i)), + imagePath = raw.image, + fullScreen = raw.fullScreen ~= false, + normalized = true, + pixelCoords = false, + rangeMod = num(raw.rangeMod, 1), + alphaMod = num(raw.alphaMod, 1), + aspect = num(raw.aspect, DEFAULT_ASPECT), + aspectFromCfg = raw.fitAspect == true, + orient = (raw.orient == "portrait" or raw.orient == "landscape" + or raw.orient == "any") and raw.orient or nil, + screenFit = raw.screenFit == "remainder" and "remainder" or nil, + anchor = (raw.anchor == "top" or raw.anchor == "bottom" + or raw.anchor == "left" or raw.anchor == "right") + and raw.anchor or nil, + rect = { x = 0, y = 0, w = 1, h = 1 }, + controls = {}, + } + if type(raw.rect) == "table" then + page.rect = { x = num(raw.rect.x, 0), y = num(raw.rect.y, 0), + w = num(raw.rect.w, 1), h = num(raw.rect.h, 1) } + end + if type(raw.viewport) == "table" then + page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0), + w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) } + page.viewportFill = raw.viewport.fill == true + end + for _, c in ipairs(raw.controls or {}) do + local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul") + local sector = tonumber(c.sector) + if sector then + sector = math.floor(sector) + if sector < 1 or sector > #SECTOR_CELLS then sector = nil end + end + local areaNames + if type(c.areaNames) == "table" then + areaNames = {} + for _, side in ipairs({ "up", "down", "left", "right" }) do + if type(c.areaNames[side]) == "string" then + areaNames[side] = c.areaNames[side] + end + end + end + page.controls[#page.controls + 1] = { + sector = sector, + areaKind = type(c.areaKind) == "string" and c.areaKind or nil, + areaNames = areaNames, + spec = tostring(c.bind or "nul"), + buttons = buttons, hotkeys = hotkeys, keys = keys, + decorative = decorative, + x = num(c.x, 0.5), y = num(c.y, 0.5), + shape = c.shape == "radial" and "radial" or "rect", + rangeX = math.abs(num(c.w, 0.1)) * 0.5, + rangeY = math.abs(num(c.h, 0.1)) * 0.5, + rangeMod = num(c.rangeMod, page.rangeMod), + alphaMod = num(c.alphaMod, page.alphaMod), + reachUp = num(c.reachUp, 1), reachDown = num(c.reachDown, 1), + reachLeft = num(c.reachLeft, 1), reachRight = num(c.reachRight, 1), + imagePath = c.image, + pressedImagePath = c.imagePressed, + nextTarget = c.nextTarget, + movable = c.movable == true or nil, + exclusive = c.exclusive == true or nil, + } + end + if not page.orient then page.orient = TouchSkin.pageOrient(page) end + pages[#pages + 1] = page + end + return { pages = pages, name = data.name, author = data.author, + notes = data.notes, format = "native" } +end + +function TouchSkin.toNative(skin) + local out = { + name = skin.name or skin.id, + author = skin.author, + notes = skin.notes, + format = 1, + pages = {}, + } + for _, page in ipairs(skin.pages or {}) do + local p = { + name = page.name, + image = page.imagePath, + fullScreen = page.fullScreen ~= false, + rangeMod = page.rangeMod, + alphaMod = page.alphaMod, + aspect = page.aspect, + fitAspect = page.aspectFromCfg or nil, + screenFit = page.screenFit == "remainder" and "remainder" or nil, + anchor = page.anchor, + orient = (page.orient == "portrait" or page.orient == "landscape" + or page.orient == "any") and page.orient or nil, + controls = {}, + } + if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0 + or page.rect.w ~= 1 or page.rect.h ~= 1) then + p.rect = { x = page.rect.x, y = page.rect.y, w = page.rect.w, h = page.rect.h } + end + if page.viewport then + p.viewport = { + x = page.viewport.x, y = page.viewport.y, + w = page.viewport.w, h = page.viewport.h, + fill = page.viewportFill or nil, + } + end + for _, ctl in ipairs(page.controls or {}) do + p.controls[#p.controls + 1] = { + bind = ctl.spec, + x = ctl.x, y = ctl.y, + w = ctl.rangeX * 2, h = ctl.rangeY * 2, + shape = ctl.shape, + rangeMod = ctl.rangeMod ~= p.rangeMod and ctl.rangeMod or nil, + alphaMod = ctl.alphaMod ~= p.alphaMod and ctl.alphaMod or nil, + reachUp = ctl.reachUp ~= 1 and ctl.reachUp or nil, + reachDown = ctl.reachDown ~= 1 and ctl.reachDown or nil, + reachLeft = ctl.reachLeft ~= 1 and ctl.reachLeft or nil, + reachRight = ctl.reachRight ~= 1 and ctl.reachRight or nil, + image = ctl.imagePath, + imagePressed = ctl.pressedImagePath, + nextTarget = ctl.nextTarget, + movable = ctl.movable or nil, + exclusive = ctl.exclusive or nil, + sector = ctl.sector, + areaKind = ctl.areaKind, + areaNames = ctl.areaNames and { + up = ctl.areaNames.up, down = ctl.areaNames.down, + left = ctl.areaNames.left, right = ctl.areaNames.right, + } or nil, + } + end + out.pages[#out.pages + 1] = p + end + return out +end + +function TouchSkin.serialize(skin) + return require("src.import.LuaWriter").encode(TouchSkin.toNative(skin)) +end + +local imageCache = setmetatable({}, { __mode = "v" }) + +local function loadImage(path) + if not (love and love.graphics and love.graphics.newImage) then return nil end + local cached = imageCache[path] + if cached then return cached end + local ok, img = pcall(love.graphics.newImage, path) + if not ok or not img then return nil end + if img.setFilter then img:setFilter("linear", "linear") end + imageCache[path] = img + return img +end + +-- FileData so LOVE sniffs JPEG/PNG from the name, not a path inside the zip. +local function loadImageFromBytes(bytes, name) + if not bytes or bytes == "" then return nil end + if not (love and love.graphics and love.graphics.newImage) then return nil end + if not (love.filesystem and love.filesystem.newFileData) then return nil end + local key = "bytes:" .. tostring(name) .. ":" .. tostring(#bytes) + local cached = imageCache[key] + if cached then return cached end + local okFd, fd = pcall(love.filesystem.newFileData, bytes, name or "bezel.jpg") + if not okFd or not fd then return nil end + local ok, img = pcall(love.graphics.newImage, fd) + if not ok or not img then + if love.image and love.image.newImageData then + local okData, data = pcall(love.image.newImageData, fd) + if okData and data then ok, img = pcall(love.graphics.newImage, data) end + end + end + if not ok or not img then return nil end + if img.setFilter then img:setFilter("linear", "linear") end + imageCache[key] = img + return img +end + +local function rasterizePdfPage(page, root) + if not page or page.image or not page.pdfPath then return end + local pdf = readFile(joinPath(root, page.pdfPath)) + local raster = require("src.core.PdfImage").extract(pdf) + if not raster then return end + local name = tostring(page.pdfPath):gsub("%.[Pp][Dd][Ff]$", "") .. "." .. raster.ext + page.rasterData = raster.data + page.rasterName = name:match("([^/]+)$") or name + page.image = loadImageFromBytes(raster.data, page.rasterName) +end + +local function pixelScalePending(page) + if page.pixelCoords then return true end + for _, ctl in ipairs(page.controls or {}) do + if ctl.pixelCoords then return true end + end + return false +end + +local function applyPixelScale(page) + if not pixelScalePending(page) then return true end + if not page.image or not page.image.getDimensions then return false end + local iw, ih = page.image:getDimensions() + if not iw or not ih or iw <= 0 or ih <= 0 then return false end + for _, ctl in ipairs(page.controls or {}) do + local pixel = ctl.pixelCoords + if pixel == nil then pixel = page.pixelCoords end + if pixel then + ctl.x, ctl.y = ctl.x / iw, ctl.y / ih + ctl.rangeX, ctl.rangeY = ctl.rangeX / iw, ctl.rangeY / ih + ctl.pixelCoords = false + end + end + page.pixelCoords = false + return true +end + +-- An overlay image is its own design canvas. Older RetroArch cfg files often +-- omit `aspect_ratio`; reading the dimensions here keeps that legacy art and +-- all of its normalized controls on the same uniform scale. +function TouchSkin.applyImageAspect(page) + if not page or page.aspectFromCfg or not page.image + or not page.image.getDimensions then return false end + local iw, ih = page.image:getDimensions() + if not iw or not ih or iw <= 0 or ih <= 0 then return false end + page.aspect = iw / ih + page.aspectFromImage = true + return true +end + +function TouchSkin.load(root, id) + local cfgPath, format, prefix = findConfig(root) + if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end + local text = readFile(cfgPath) + if not text then return nil, "unreadable " .. cfgPath end + local skin, err + if format == "native" then + skin, err = TouchSkin.parseNative(text) + elseif format == "delta" then + local dir = cfgPath:match("^(.*)/[^/]+$") or root + skin, err = require("src.core.DeltaSkin").parse(text, + { prefix = prefix or "", names = listDir(dir) }) + else + skin, err = TouchSkin.parse(text) + end + if not skin then return nil, err end + + skin.id = id or root:match("([^/]+)$") or root + skin.root = root + skin.configPath = cfgPath + skin.format = format + skin.name = skin.name or skin.pages[1] and skin.pages[1].name or skin.id + + for _, page in ipairs(skin.pages) do + if page.imagePath then + page.image = loadImage(joinPath(root, page.imagePath)) + elseif page.pdfPath then + rasterizePdfPage(page, root) + end + TouchSkin.applyImageAspect(page) + if not applyPixelScale(page) then + return nil, "could not read " .. tostring(page.imagePath) + .. ", which " .. page.name .. " measures its coordinates against" + end + for _, ctl in ipairs(page.controls) do + if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end + if ctl.pressedImagePath then + ctl.pressedImage = loadImage(joinPath(root, ctl.pressedImagePath)) + end + end + end + return skin +end + +local function mountZip(archive, point) + if not (love and love.filesystem and love.filesystem.mount) then return false end + local ok, mounted = pcall(love.filesystem.mount, archive, point) + return ok and mounted == true +end + +TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true } +TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true } +TouchSkin.PDF_ONLY_MESSAGE = + "This skin uses PDF artwork with no extractable image. " + .. "Ask the author for a PNG version." + +function TouchSkin.archiveId(name) + name = tostring(name or "") + local ext = name:match("%.([%w]+)$") + if not ext or not TouchSkin.ARCHIVE_EXTS[ext:lower()] then return nil end + local id = name:sub(1, #name - #ext - 1) + if id == "" then return nil end + return id, ext:lower() +end + +function TouchSkin.list() + local out, seen = {}, {} + local function scan(root, source) + for _, name in ipairs(listDir(root)) do + local archiveId = TouchSkin.archiveId(name) + local id = archiveId or name + if not seen[id] and name:sub(1, 1) ~= "_" then + local path = root .. "/" .. name + if archiveId then + local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id + if mountZip(path, point) and findConfig(point) then + seen[id] = true + out[#out + 1] = { id = id, root = point, source = source, archive = path } + end + elseif isDir(path) and findConfig(path) then + seen[id] = true + out[#out + 1] = { id = id, root = path, source = source } + end + end + end + end + if love and love.filesystem and love.filesystem.createDirectory then + pcall(wfs().createDirectory, TouchSkin.USER_ROOT) + end + scan(TouchSkin.USER_ROOT, "user") + scan(TouchSkin.BUNDLED_ROOT, "bundled") + table.sort(out, function(a, b) return a.id < b.id end) + return out +end + +-- Drop a .zip or .deltaskin into /skins and report the id it lists under. +function TouchSkin.installArchive(name, data) + if not data or data == "" then return nil, "empty archive" end + if not (love and love.filesystem and love.filesystem.write) then + return nil, "no writable filesystem" + end + name = tostring(name or ""):match("([^/\\]+)$") or "" + name = name:gsub("[^%w%._%-]", "_"):gsub("^_+", "") + local legacy = name:match("%.([%w]+)$") + if legacy and TouchSkin.LEGACY_EXTS[legacy:lower()] then + return nil, "old GBA4iOS skin, not supported" + end + local id = TouchSkin.archiveId(name) + if not id then return nil, "not a .zip or .deltaskin" end + + pcall(wfs().createDirectory, TouchSkin.USER_ROOT) + local dest = TouchSkin.USER_ROOT .. "/" .. name + local ok, err = wfs().write(dest, data) + if not ok then return nil, tostring(err) end + + local entry = TouchSkin.find(id) + if not entry then + wfs().remove(dest) + return nil, "no skin.lua, .cfg or info.json inside " .. name + end + local skin = TouchSkin.load(entry.root, entry.id) + if skin and require("src.core.DeltaSkin").needsConversion(skin) then + wfs().remove(dest) + return nil, TouchSkin.PDF_ONLY_MESSAGE + end + return id, skin and skin.warnings or nil +end + +function TouchSkin.find(id) + if not id or id == "" then return nil end + for _, entry in ipairs(TouchSkin.list()) do + if entry.id == id then return entry end + end + return nil +end + +-- Remove only a user-installed skin. Bundled skins are shipped with the +-- game and intentionally have no delete affordance. +function TouchSkin.remove(id) + local entry = TouchSkin.find(id) + if not entry then return nil, "no skin " .. tostring(id) end + if entry.source ~= "user" then return nil, "bundled skins cannot be deleted" end + if not (love and love.filesystem and love.filesystem.remove) then + return nil, "no writable filesystem" + end + local function removeTree(path) + if isDir(path) and love.filesystem.getDirectoryItems then + for _, name in ipairs(love.filesystem.getDirectoryItems(path)) do + local ok, err = removeTree(path .. "/" .. name) + if not ok then return nil, err end + end + end + local ok, err = wfs().remove(path) + return ok and true or nil, err + end + return removeTree(entry.archive or (TouchSkin.USER_ROOT .. "/" .. entry.id)) +end + +function TouchSkin.assetPaths(skin) + local out, seen = {}, {} + local function add(rel) + if rel and rel ~= "" and not seen[rel] then + seen[rel] = true + out[#out + 1] = rel + end + end + for _, page in ipairs(skin.pages or {}) do + add(page.imagePath) + for _, ctl in ipairs(page.controls or {}) do + add(ctl.imagePath) + add(ctl.pressedImagePath) + end + end + return out +end + +local function writeArchive(entries, destPath) + local blob = require("src.core.SkinZip").encode(entries) + local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil + if not absolute and love and love.filesystem and love.filesystem.createDirectory then + local dir = destPath:match("^(.*)/[^/]+$") + if dir then pcall(wfs().createDirectory, dir) end + end + if not absolute and love and love.filesystem and love.filesystem.write then + local ok, err = wfs().write(destPath, blob) + if not ok then return nil, tostring(err) end + else + local handle = io.open(destPath, "wb") + if not handle then return nil, "cannot write " .. destPath end + handle:write(blob) + handle:close() + end + return destPath +end + +local function collectAssets(skin, rels) + local entries, missing = {}, {} + for _, rel in ipairs(rels) do + local data = readFile(joinPath(skin.root, rel)) + if data then + entries[#entries + 1] = { name = rel, data = data } + else + missing[#missing + 1] = rel + end + end + return entries, missing +end + +function TouchSkin.export(skin, destPath) + if not skin then return nil, "no skin" end + local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } } + local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin)) + for _, entry in ipairs(assets) do entries[#entries + 1] = entry end + if skin.configPath and skin.format == "retroarch" then + local cfg = readFile(skin.configPath) + if cfg then + entries[#entries + 1] = + { name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg } + end + end + destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-export.zip") + local written, err = writeArchive(entries, destPath) + if not written then return nil, err end + return destPath, missing +end + +local function fmtNum(n) + n = tonumber(n) or 0 + if n == math.floor(n) then return string.format("%d", n) end + local s = string.format("%.6f", n):gsub("0+$", ""):gsub("%.$", "") + return s +end + +local function fmtRect(r) + return ('"%s,%s,%s,%s"'):format(fmtNum(r.x), fmtNum(r.y), fmtNum(r.w), fmtNum(r.h)) +end + +local function cfgSpec(spec) + local parts = {} + for raw in tostring(spec or ""):gmatch("[^|]+") do + local name = trim(raw) + local key = name:lower():match("^key:(.+)$") + parts[#parts + 1] = key and ("retrok_" .. key) or name + end + return table.concat(parts, "|") +end + +function TouchSkin.toRetroArchConfig(skin) + local pages = (skin and skin.pages) or {} + local out = { "overlays = " .. #pages } + for i, page in ipairs(pages) do + local p = "overlay" .. (i - 1) + out[#out + 1] = "" + out[#out + 1] = p .. '_name = "' .. tostring(page.name or ("overlay" .. (i - 1))) .. '"' + if page.imagePath then out[#out + 1] = p .. "_overlay = " .. page.imagePath end + out[#out + 1] = p .. "_full_screen = " .. (page.fullScreen ~= false and "true" or "false") + out[#out + 1] = p .. "_normalized = true" + if num(page.rangeMod, 1) ~= 1 then + out[#out + 1] = p .. "_range_mod = " .. fmtNum(page.rangeMod) + end + if num(page.alphaMod, 1) ~= 1 then + out[#out + 1] = p .. "_alpha_mod = " .. fmtNum(page.alphaMod) + end + if page.aspectFromCfg and page.aspect and page.aspect > 0 then + out[#out + 1] = p .. "_aspect_ratio = " .. fmtNum(page.aspect) + end + local r = page.rect + if r and (r.x ~= 0 or r.y ~= 0 or r.w ~= 1 or r.h ~= 1) then + out[#out + 1] = p .. "_rect = " .. fmtRect(r) + end + if page.viewport then + out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport) + if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end + end + local controls = {} + for _, ctl in ipairs(page.controls or {}) do + if not ctl.sector or ctl.sector == 1 then controls[#controls + 1] = ctl end + end + out[#out + 1] = p .. "_descs = " .. #controls + for j, ctl in ipairs(controls) do + local d = p .. "_desc" .. (j - 1) + local spec = ctl.areaKind and ctl.sector and ctl.areaKind + or cfgSpec(ctl.spec) + if spec == "" then spec = "nul" end + out[#out + 1] = ('%s = "%s,%s,%s,%s,%s,%s"'):format(d, spec, + fmtNum(ctl.x), fmtNum(ctl.y), + ctl.shape == "radial" and "radial" or "rect", + fmtNum(ctl.rangeX), fmtNum(ctl.rangeY)) + if ctl.imagePath then out[#out + 1] = d .. "_overlay = " .. ctl.imagePath end + if ctl.pressedImagePath then + out[#out + 1] = d .. "_overlay_pressed = " .. ctl.pressedImagePath + end + if num(ctl.rangeMod, 1) ~= num(page.rangeMod, 1) then + out[#out + 1] = d .. "_range_mod = " .. fmtNum(ctl.rangeMod) + end + if num(ctl.alphaMod, 1) ~= num(page.alphaMod, 1) then + out[#out + 1] = d .. "_alpha_mod = " .. fmtNum(ctl.alphaMod) + end + for key, value in pairs({ up = ctl.reachUp, down = ctl.reachDown, + left = ctl.reachLeft, right = ctl.reachRight }) do + if num(value, 1) ~= 1 then + out[#out + 1] = d .. "_reach_" .. key .. " = " .. fmtNum(value) + end + end + if ctl.movable then out[#out + 1] = d .. "_movable = true" end + if ctl.exclusive then out[#out + 1] = d .. "_exclusive = true" end + if ctl.nextTarget then + out[#out + 1] = d .. '_next_target = "' .. tostring(ctl.nextTarget) .. '"' + end + if ctl.areaKind and ctl.sector and ctl.areaNames then + local defaults = TouchSkin.AREA_DEFAULTS[ctl.areaKind] or {} + for _, side in ipairs({ "up", "down", "left", "right" }) do + local name = ctl.areaNames[side] + if name and name ~= defaults[side] then + out[#out + 1] = d .. "_" .. side .. ' = "' .. name .. '"' + end + end + end + end + end + return table.concat(out, "\n") .. "\n" +end + +function TouchSkin.exportRetroArch(skin, destPath) + if not skin then return nil, "no skin" end + local entries = { { name = "overlay.cfg", data = TouchSkin.toRetroArchConfig(skin) } } + local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin)) + for _, entry in ipairs(assets) do entries[#entries + 1] = entry end + destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-retroarch.zip") + local written, err = writeArchive(entries, destPath) + if not written then return nil, err end + return destPath, missing +end + +function TouchSkin.exportDelta(skin, opts) + if not skin then return nil, "no skin" end + opts = opts or {} + local DeltaSkin = require("src.core.DeltaSkin") + local info, assetRels, warnings = DeltaSkin.build(skin, opts) + if not info then return nil, assetRels end + local entries = { + { name = DeltaSkin.INFO_NAME, data = require("src.link.Json").encode(info) }, + } + local assets, missing = collectAssets(skin, assetRels) + for _, entry in ipairs(assets) do entries[#entries + 1] = entry end + local destPath = opts.path + or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. ".deltaskin") + local written, err = writeArchive(entries, destPath) + if not written then return nil, err end + return destPath, missing, warnings +end + +TouchSkin.BINDS = { + "nul", + "up", "down", "left", "right", + "a", "b", "l", "r", "start", "select", + "left|up", "right|up", "left|down", "right|down", + "hold_fast_forward", "toggle_fast_forward", "reset", "menu_toggle", + "overlay_next", +} + +function TouchSkin.describeBind(spec) + local buttons, hotkeys, keys, decorative = parseBinds(spec) + if decorative then return "decoration" end + local parts = {} + for _, b in ipairs(buttons) do parts[#parts + 1] = "GB " .. b:upper() end + for _, h in ipairs(hotkeys) do parts[#parts + 1] = h end + for _, k in ipairs(keys) do parts[#parts + 1] = "key " .. k end + return table.concat(parts, " + ") +end + +function TouchSkin.newControl(spec, x, y, w, h, shape) + local buttons, hotkeys, keys, decorative = parseBinds(spec) + return { + spec = spec, buttons = buttons, hotkeys = hotkeys, keys = keys, + decorative = decorative, + x = x, y = y, rangeX = w * 0.5, rangeY = h * 0.5, + shape = shape == "radial" and "radial" or "rect", + rangeMod = 1, alphaMod = 1, + reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1, + } +end + +function TouchSkin.setBind(ctl, spec) + ctl.spec = spec + ctl.buttons, ctl.hotkeys, ctl.keys, ctl.decorative = parseBinds(spec) + return ctl +end + +function TouchSkin.newSkin(id) + local page = { + index = 1, name = "main", fullScreen = true, normalized = true, + rangeMod = 1, alphaMod = 1, aspect = PORTRAIT_ASPECT, + rect = { x = 0, y = 0, w = 1, h = 1 }, + viewport = { x = 0, y = 0, w = 1, h = 0.5 }, + viewportFill = false, + controls = {}, + } + return { id = id or "new_skin", name = id or "new_skin", + root = TouchSkin.USER_ROOT .. "/" .. (id or "new_skin"), + format = "native", pages = { page } } +end + +local function copyTable(src) + if type(src) ~= "table" then return src end + local out = {} + for k, v in pairs(src) do out[k] = copyTable(v) end + return out +end + +function TouchSkin.clone(skin) + if not skin then return nil end + local out = { + id = skin.id, name = skin.name, root = skin.root, format = skin.format, + author = skin.author, notes = skin.notes, configPath = skin.configPath, + source = skin.source, pages = {}, + warnings = skin.warnings and copyTable(skin.warnings) or nil, + } + for i, page in ipairs(skin.pages or {}) do + local p = copyTable(page) + p.image = page.image + p.controls = {} + for j, ctl in ipairs(page.controls or {}) do + local c = copyTable(ctl) + c.image = ctl.image + c.pressedImage = ctl.pressedImage + p.controls[j] = c + end + out.pages[i] = p + end + return out +end + +function TouchSkin.resolveImage(root, rel) + if not rel or rel == "" then return nil end + return loadImage(joinPath(root, rel)) +end + +function TouchSkin.detectViewport(root, imagePath) + if not (love and love.image and love.image.newImageData) then return nil end + if not imagePath or imagePath == "" then return nil end + local ok, data = pcall(love.image.newImageData, joinPath(root, imagePath)) + if not ok or not data then return nil end + local w, h = data:getWidth(), data:getHeight() + if w < 2 or h < 2 then return nil end + + local function clear(x, y) + if x < 0 or y < 0 or x >= w or y >= h then return false end + local okp, _, _, _, a = pcall(data.getPixel, data, x, y) + return okp and a ~= nil and a < 0.05 + end + + local cx, cy = math.floor(w / 2), math.floor(h / 2) + if not clear(cx, cy) then return nil end + + local left, right = cx, cx + while left > 0 and clear(left - 1, cy) do left = left - 1 end + while right < w - 1 and clear(right + 1, cy) do right = right + 1 end + local top, bottom = cy, cy + while top > 0 and clear(cx, top - 1) do top = top - 1 end + while bottom < h - 1 and clear(cx, bottom + 1) do bottom = bottom + 1 end + + local rw, rh = right - left + 1, bottom - top + 1 + if rw < 8 or rh < 8 then return nil end + return { x = left / w, y = top / h, w = rw / w, h = rh / h }, rw, rh +end + +-- Bring outside art into the skin's own folder. A skin still living in +-- assets/ or a mounted zip is saved out to /skins/ first, because +-- that is the only root the engine can write to. +function TouchSkin.importImage(skin, name, data) + if not skin then return nil, "no skin" end + if not data or data == "" then return nil, "no image data" end + if not (love and love.filesystem and love.filesystem.write) then + return nil, "no writable filesystem" + end + name = tostring(name):gsub("[^%w%._%-]", "_") + if name == "" then return nil, "bad file name" end + + local dest = TouchSkin.USER_ROOT .. "/" .. skin.id + if skin.root ~= dest then + local saved, err = TouchSkin.saveTo(skin, skin.id) + if not saved then return nil, tostring(err) end + end + pcall(wfs().createDirectory, dest .. "/img") + local rel = "img/" .. name + local ok, err = wfs().write(dest .. "/" .. rel, data) + if not ok then return nil, tostring(err) end + return rel +end + +function TouchSkin.listImages(root) + local out = {} + local function scan(dir, prefix) + for _, name in ipairs(listDir(dir)) do + local path = dir .. "/" .. name + local lower = name:lower() + if lower:match("%.png$") or lower:match("%.jpg$") or lower:match("%.jpeg$") then + out[#out + 1] = prefix .. name + elseif isDir(path) and prefix == "" then + scan(path, name .. "/") + end + end + end + scan(root, "") + table.sort(out) + return out +end + +function TouchSkin.saveTo(skin, id) + id = id or skin.id + if not id or id == "" then return nil, "no skin id" end + if not (love and love.filesystem and love.filesystem.write) then + return nil, "no writable filesystem" + end + local dest = TouchSkin.USER_ROOT .. "/" .. id + pcall(wfs().createDirectory, dest) + + local copied, failed = 0, {} + for _, rel in ipairs(TouchSkin.assetPaths(skin)) do + local target = dest .. "/" .. rel + local dir = target:match("^(.*)/[^/]+$") + if dir then pcall(wfs().createDirectory, dir) end + if skin.root ~= dest then + local data = readFile(joinPath(skin.root, rel)) + if data then + if wfs().write(target, data) then copied = copied + 1 end + else + failed[#failed + 1] = rel + end + end + end + + local ok, err = wfs().write(dest .. "/" .. TouchSkin.NATIVE_NAME, + TouchSkin.serialize(skin)) + if not ok then return nil, tostring(err) end + skin.id, skin.root, skin.format = id, dest, "native" + return dest, failed, copied +end + +TouchSkin.active = nil +TouchSkin.pageIndex = 1 +-- RetroArch Auto-Rotate Overlay (1.7.9, default on mobile): a cfg whose +-- pages are named portrait / landscape is swapped to match the display. +-- The Skin Studio turns this off so PAGE and the canvas preset stay independent. +TouchSkin.autoOrient = true + +TouchSkin.surfaceRect = nil + +function TouchSkin.setSurface(x, y, w, h) + if not w or w <= 0 or not h or h <= 0 then + TouchSkin.surfaceRect = nil + else + TouchSkin.surfaceRect = { x = x, y = y, w = w, h = h } + end + return TouchSkin.surfaceRect +end + +function TouchSkin.setActive(skin) + TouchSkin.active = skin or nil + TouchSkin.pageIndex = 1 + return TouchSkin.active +end + +function TouchSkin.select(id) + if not id or id == "" then return TouchSkin.setActive(nil) end + local entry = TouchSkin.find(id) + if not entry then return nil, "no skin " .. tostring(id) end + local skin, err = TouchSkin.load(entry.root, entry.id) + if not skin then return nil, err end + skin.source = entry.source + return TouchSkin.setActive(skin) +end + +local function displaySize() + local r = TouchSkin.surfaceRect + if r and r.w and r.h and r.w > 0 and r.h > 0 then return r.w, r.h end + if love and love.graphics and love.graphics.getDimensions then + return love.graphics.getDimensions() + end + return 0, 0 +end + +-- Explicit lock (studio) wins; otherwise the page name, the RetroArch +-- auto-rotate convention. "any" means unlocked even if the name says +-- portrait or landscape. +function TouchSkin.pageOrient(page) + if not page then return nil end + if page.orient == "any" then return nil end + if page.orient == "portrait" or page.orient == "landscape" then + return page.orient + end + local n = tostring(page.name or ""):lower() + if n:find("landscape", 1, true) then return "landscape" end + if n:find("portrait", 1, true) then return "portrait" end + return nil +end + +function TouchSkin.hasOrientPair(skin) + local saw = {} + for _, page in ipairs(skin and skin.pages or {}) do + local o = TouchSkin.pageOrient(page) + if o then saw[o] = true end + end + return saw.portrait == true and saw.landscape == true +end + +local function findOrientPage(skin, keyword) + for i, page in ipairs(skin.pages or {}) do + if TouchSkin.pageOrient(page) == keyword then return i end + end + return nil +end + +-- If the current page is the wrong orientation of a portrait/landscape pair, +-- jump to the matching one. Pages locked to neither (gb_anim's GameBoy / +-- GameBoyColor) are left alone. #1503 +function TouchSkin.syncOrientation(w, h) + if not TouchSkin.autoOrient then return end + local skin = TouchSkin.active + if not skin or not w or not h or w <= 0 or h <= 0 then return end + local want = w > h and "landscape" or "portrait" + local unwant = w > h and "portrait" or "landscape" + local page = skin.pages[TouchSkin.pageIndex] or skin.pages[1] + local current = TouchSkin.pageOrient(page) + if current == want then return end + if current ~= unwant then return end + local idx = findOrientPage(skin, want) + if idx then TouchSkin.pageIndex = idx end +end + +function TouchSkin.page() + local skin = TouchSkin.active + if not skin then return nil end + local w, h = displaySize() + TouchSkin.syncOrientation(w, h) + return skin.pages[TouchSkin.pageIndex] or skin.pages[1] +end + +function TouchSkin.setPage(target) + local skin = TouchSkin.active + if not skin then return nil end + if type(target) == "number" then + local n = #skin.pages + TouchSkin.pageIndex = ((math.floor(target) - 1) % n) + 1 + return TouchSkin.page() + end + for i, page in ipairs(skin.pages) do + if page.name == target then + TouchSkin.pageIndex = i + return page + end + end + return TouchSkin.page() +end + +function TouchSkin.nextPage(target) + local skin = TouchSkin.active + if not skin then return nil end + if target and target ~= "" then return TouchSkin.setPage(target) end + return TouchSkin.setPage(TouchSkin.pageIndex + 1) +end + +function TouchSkin.pageBox(page, w, h, ox, oy) + ox, oy = ox or 0, oy or 0 + if not page then return ox, oy, w, h end + local bx, by, bw, bh = ox, oy, w, h + -- full_screen means "relative to the window, not the game viewport". + -- When the cfg also names an aspect_ratio, that window is then fitted + -- to the overlay's design aspect so buttons do not stretch. #1503 + local fit = ((not page.fullScreen) or page.aspectFromCfg or page.aspectFromImage) + and page.aspect and page.aspect > 0 and h > 0 + if fit then + local displayAspect = w / h + local anchor = page.anchor + if displayAspect > page.aspect then + bw = h * page.aspect + local extra = w - bw + if anchor == "right" then + bx = ox + extra + elseif anchor == "left" then + bx = ox + else + bx = ox + extra * 0.5 + end + else + bh = w / page.aspect + local extra = h - bh + if anchor == "bottom" then + by = oy + extra + elseif anchor == "top" then + by = oy + elseif page.aspect < 1 then + -- A portrait bezel with controls is a controller deck. On an + -- unusually tall display, pin the deck to the lower edge and leave + -- the additional room for the game above it. + by = oy + extra + else + by = oy + extra * 0.5 + end + end + end + local r = page.rect + return bx + r.x * bw, by + r.y * bh, r.w * bw, r.h * bh +end + +function TouchSkin.imageFit(iw, ih, w, h) + if not iw or not ih or iw <= 0 or ih <= 0 then return nil end + return w / iw, h / ih +end + +function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy) + local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy) + local cx, cy = bx + ctl.x * bw, by + ctl.y * bh + local halfW, halfH = ctl.rangeX * bw, ctl.rangeY * bh + return cx, cy, halfW, halfH +end + +function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy, held) + local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy) + local mod = held == false and 1 or ctl.rangeMod + local left = halfW * ctl.reachLeft * mod + local right = halfW * ctl.reachRight * mod + local up = halfH * ctl.reachUp * mod + local down = halfH * ctl.reachDown * mod + local dx = px - cx + local dy = py - cy + local rx = dx < 0 and left or right + local ry = dy < 0 and up or down + if rx <= 0 or ry <= 0 then return false end + if ctl.sector and not TouchSkin.sectorHit(ctl.sector, dx, dy) then + return false + end + if ctl.shape == "radial" then + return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1 + end + return math.abs(dx) <= rx and math.abs(dy) <= ry +end + +TouchSkin.overlayLive = false + +function TouchSkin.setOverlayLive(on) + TouchSkin.overlayLive = on and true or false +end + +function TouchSkin.decorativeOnly() + local page = TouchSkin.page() + if not page then return false end + for _, ctl in ipairs(page.controls) do + if not ctl.decorative then return false end + end + return true +end + +function TouchSkin.drawable() + -- A selected skin is a presentation choice, not a mobile-only input mode. + -- Its artwork and screen placement therefore belong on every platform; + -- `overlayLive` still controls whether touch input is available. + return TouchSkin.active ~= nil +end + +function TouchSkin.hasViewport() + local page = TouchSkin.page() + if not page or not TouchSkin.drawable() then return false end + return page.viewport ~= nil or page.screenFit == "remainder" +end + +-- Largest strip of (ox,oy,w,h) that does not overlap the overlay box. +local function remainderBox(ox, oy, w, h, bx, by, bw, bh) + local right, bottom = ox + w, oy + h + local cand = { + { ox, oy, w, by - oy }, + { ox, by + bh, w, bottom - (by + bh) }, + { ox, oy, bx - ox, h }, + { bx + bw, oy, right - (bx + bw), h }, + } + local best, bestArea + for _, r in ipairs(cand) do + if r[3] > 1 and r[4] > 1 then + local area = r[3] * r[4] + if not best or area > bestArea then + best, bestArea = r, area + end + end + end + if not best then return nil end + return best[1], best[2], best[3], best[4] +end + +-- The deck box pinned to the lower edge (see pageBox) leaves room above it +-- that belongs to the game, so a screen rect flush with the top of the deck +-- grows into it instead of showing a black band. +local function deckHeadroom(y, vh, by, bh, oy, h) + if by <= oy + 0.5 then return y, vh end + if by + bh < oy + h - 0.5 then return y, vh end + if y - by > math.max(2, bh * 0.01) then return y, vh end + return oy, vh + (y - oy) +end + +function TouchSkin.pageViewport(page, w, h, ox, oy) + if not page then return nil end + ox, oy = ox or 0, oy or 0 + local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy) + if page.viewport then + local v = page.viewport + local x, y = bx + v.x * bw, by + v.y * bh + local vw, vh = v.w * bw, v.h * bh + if vw <= 0 or vh <= 0 then return nil end + y, vh = deckHeadroom(y, vh, by, bh, oy, h) + return x, y, vw, vh, page.viewportFill == true + end + if page.screenFit == "remainder" then + local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh) + if not x then return nil end + return x, y, vw, vh, false + end + return nil +end + +-- Centre of the page's screen cutout. The renderer fits the 160x144 +-- picture into that rect; this helper is for the studio preview. +function TouchSkin.screenCenter(w, h, ox, oy, page) + page = page or TouchSkin.page() + if not page then return nil end + local x, y, vw, vh = TouchSkin.pageViewport(page, w, h, ox, oy) + if not x then return nil end + return x + vw * 0.5, y + vh * 0.5 +end + +function TouchSkin.viewport(w, h, ox, oy) + local page = TouchSkin.page() + if not page or not TouchSkin.drawable() then return nil end + return TouchSkin.pageViewport(page, w, h, ox, oy) +end + +return TouchSkin diff --git a/src/render/GameViewport.lua b/src/render/GameViewport.lua index 038cdd247..14c0bdada 100644 --- a/src/render/GameViewport.lua +++ b/src/render/GameViewport.lua @@ -23,6 +23,7 @@ function V.reset() return end release() + V.skinFill = false V.rect = nil V.full = nil V.generation = nil @@ -67,11 +68,20 @@ function V.begin(generation, allowed) ph = finite(ph) and math.max(1, ph) or h local context = { width = w, height = h, pixelWidth = pw, pixelHeight = ph, dpiX = pw / w, dpiY = ph / h, generation = generation } - local requested + local requested, skinFill + if allowed ~= false then + local Skin = require("src.core.TouchSkin") + local ok, x, y, sw, sh, fill = pcall(Skin.viewport, w, h) + if ok and finite(x) and finite(y) and finite(sw) and finite(sh) and sw > 0 and sh > 0 then + requested = { x = x, y = y, width = sw, height = sh } + skinFill = fill == true + end + end + local skinRequest = requested if allowed ~= false and Runtime.wantsHook("render.viewport") then local ok, value = Scope.call(function() return Runtime.call("render.viewport", function() - return { x = 0, y = 0, width = w, height = h } + return skinRequest or { x = 0, y = 0, width = w, height = h } end, context) end) if ok then @@ -86,6 +96,7 @@ function V.begin(generation, allowed) local r, capture = rect(requested, w, h) local active = capture or r.x ~= 0 or r.y ~= 0 or r.width ~= w or r.height ~= h V.rect, V.generation, V.frameActive = r, generation, active + V.skinFill = skinFill == true and requested == skinRequest if active then local key = table.concat({ r.width, r.height, pw / w, ph / h }, ":") if V.failedSize ~= key then @@ -118,6 +129,7 @@ function V.begin(generation, allowed) end if not active then V.frameActive = false + V.skinFill = false V.rect = { x = 0, y = 0, width = w, height = h } end V.inFrame = true diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 3e7d8be13..e251985f6 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -851,7 +851,7 @@ function Renderer:endFrame(zones, worldZones) -- GB pixel stops being a whole number of screen pixels, which is the trade -- the setting exists to offer. Clamped on the horizontal too, so a narrow -- window scales to fit instead of overflowing off both sides. - if self.uiFill or self.splitViewport then + if self.uiFill or self.splitViewport or GameViewport.skinFill then Up = math.min(ph / uih, pw / uiw) end local Ux, Uy = Up / dpiX, Up / dpiY diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua index e1ab10a62..c69a6956b 100644 --- a/src/ui/TouchControlsEditor.lua +++ b/src/ui/TouchControlsEditor.lua @@ -72,6 +72,7 @@ local function persist() -- dropped once the player saves under the new shape opts.touchControls = { enabled = cfg.enabled, + skin = cfg.skin, layouts = cfg.layouts, } SaveData.saveOptions(opts) diff --git a/tests/engine/skin_format_import_test.lua b/tests/engine/skin_format_import_test.lua new file mode 100644 index 000000000..2b5452d24 --- /dev/null +++ b/tests/engine/skin_format_import_test.lua @@ -0,0 +1,704 @@ +-- From Gen1Recomp 535a5ec0, Copyright (C) 2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section 7; see licenses/GEN1RECOMP-TOUCH-SKINS.md. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local DeltaSkin = require("src.core.DeltaSkin") +local Json = require("src.link.Json") + +local function near(got, want, msg) + return check(type(got) == "number" and math.abs(got - want) < 1e-6, + ("%s (got %s, want %s)"):format(msg, tostring(got), tostring(want))) +end + +local function hasWarning(skin, fragment) + for _, w in ipairs(skin.warnings or {}) do + if tostring(w):find(fragment, 1, true) then return true end + end + return false +end + +local function unzip(bytes) + local out, i = {}, 1 + while bytes:sub(i, i + 3) == "PK\3\4" do + local function u16(off) + local a, b = bytes:byte(i + off, i + off + 1) + return a + b * 256 + end + local function u32(off) + local a, b, c, d = bytes:byte(i + off, i + off + 3) + return a + b * 256 + c * 65536 + d * 16777216 + end + local size, nameLen, extraLen = u32(18), u16(26), u16(28) + local name = bytes:sub(i + 30, i + 29 + nameLen) + local start = i + 30 + nameLen + extraLen + out[name] = bytes:sub(start, start + size - 1) + out[#out + 1] = name + i = start + size + end + return out +end + +local function readBytes(path) + local f = assert(io.open(path, "rb")) + local data = f:read("*a") + f:close() + return data +end + +local GAMEBOY_CFG = [[ +overlays = 4 + +overlay0_name = "landscape" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_range_mod = 1.5 +overlay0_alpha_mod = 2.0 +overlay0_aspect_ratio = 2.22222222222222 +overlay0_descs = 13 +overlay0_desc0 = "nul,0.0985,0.6825,rect,0.0525,0.0875" +overlay0_desc0_overlay = img/dpad.png +overlay0_desc1 = "up,0.0985,0.5950,rect,0.0175,0.0292" +overlay0_desc2 = "down,0.0985,0.7700,rect,0.0175,0.0292" +overlay0_desc3 = "left,0.0460,0.6825,rect,0.0175,0.0292" +overlay0_desc4 = "right,0.1510,0.6825,rect,0.0175,0.0292" +overlay0_desc5 = "left|up,0.0460,0.5950,rect,0.0175,0.0292" +overlay0_desc6 = "right|up,0.1510,0.5950,rect,0.0175,0.0292" +overlay0_desc7 = "left|down,0.0460,0.7700,rect,0.0175,0.0292" +overlay0_desc8 = "right|down,0.1510,0.7700,rect,0.0175,0.0292" +overlay0_desc9 = "a,0.8975,0.6300,radial,0.0525,0.0875" +overlay0_desc9_overlay = img/a.png +overlay0_desc10 = "b,0.8100,0.7350,radial,0.0525,0.0875" +overlay0_desc10_overlay = img/b.png +overlay0_desc11 = "start,0.5500,0.9000,rect,0.0500,0.0400" +overlay0_desc12 = "select,0.4500,0.9000,rect,0.0500,0.0400" + +overlay1_name = "portrait" +overlay1_full_screen = true +overlay1_normalized = true +overlay1_aspect_ratio = 0.45 +overlay1_descs = 2 +overlay1_desc0 = "a,0.8975,0.6300,radial,0.0875,0.0525" +overlay1_desc1 = "b,0.8100,0.7350,radial,0.0875,0.0525" + +overlay2_name = "menu" +overlay2_full_screen = true +overlay2_normalized = true +overlay2_descs = 1 +overlay2_desc0 = "menu_toggle,0.5,0.5,rect,0.1,0.1" + +overlay3_name = "hide" +overlay3_full_screen = true +overlay3_normalized = true +overlay3_descs = 1 +overlay3_desc0 = "overlay_next,0.95,0.05,radial,0.04,0.04" +overlay3_desc0_next_target = "landscape" +]] + +local gameboy = assert(TouchSkin.parse(GAMEBOY_CFG)) +eq(#gameboy.pages, 4, "the canonical gameboy overlay has four pages") +eq(gameboy.pages[1].orient, "landscape", "page 1 auto-rotates landscape") +eq(gameboy.pages[2].orient, "portrait", "page 2 auto-rotates portrait") +check(TouchSkin.hasOrientPair(gameboy), "so it is an auto-rotate overlay") +eq(gameboy.pages[3].orient, nil, "the menu page is not part of the pair") +eq(#gameboy.pages[1].controls, 13, "every landscape desc parsed") +check(gameboy.pages[1].controls[1].decorative, "the d-pad art desc binds nothing") +eq(gameboy.pages[1].controls[1].imagePath, "img/dpad.png", "and carries the art") +eq(gameboy.pages[1].imagePath, nil, "the overlay ships no page background") +eq(gameboy.pages[4].controls[1].nextTarget, "landscape", "hide jumps back by name") +local named = {} +for _, ctl in ipairs(gameboy.pages[1].controls) do + for _, btn in ipairs(ctl.buttons) do named[btn] = true end +end +for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do + check(named[btn], "landscape binds GB " .. btn) +end +eq(#gameboy.warnings, 0, "a well-formed overlay warns about nothing") + +local SPACED_CFG = [[ +overlays = 1 +overlay0_name = "spaced" +overlay0_normalized = true +overlay0_descs = 2 +overlay0_desc0 = "a 0.5 0.5 rect 0.05 0.05" +overlay0_desc0_saturate_pct = 0.6 +overlay0_desc0_exclusive = true +overlay0_desc0_movable = true +overlay0_desc1 = b,0.25,0.5,radial,0.05,0.05 +]] +local spaced = assert(TouchSkin.parse(SPACED_CFG)) +near(spaced.pages[1].controls[1].saturatePct, 0.6, "_saturate_pct is parsed") +check(spaced.pages[1].controls[1].exclusive, "_exclusive is parsed") +check(spaced.pages[1].controls[1].movable, "_movable is parsed on a plain desc") +eq(spaced.pages[1].controls[2].exclusive, nil, "and is not inherited") +eq(#spaced.pages[1].controls, 2, "a space-separated desc still parses") +eq(spaced.pages[1].controls[1].buttons[1], "a", "space-separated bind") +near(spaced.pages[1].controls[1].x, 0.5, "space-separated position") +eq(spaced.pages[1].controls[2].buttons[1], "b", "an unquoted desc parses too") +eq(spaced.pages[1].controls[2].shape, "radial", "and keeps its hitbox shape") + +local SHORT_CFG = [[ +overlays = 1 +overlay0_name = "short" +overlay0_normalized = true +overlay0_descs = 2 +overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05" +]] +local short = assert(TouchSkin.parse(SHORT_CFG)) +eq(#short.pages[1].controls, 1, "a missing desc is skipped, not faked") +check(hasWarning(short, "missing desc 1"), "and the importer says so") + +eq(select(1, TouchSkin.parse("overlay0_descs = 1\n")), nil, + "a cfg without the overlays key is refused") + +local AREA_CFG = [[ +overlays = 1 +overlay0_name = "portrait" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_descs = 3 +overlay0_desc0 = "dpad_area,0.2,0.7,rect,0.15,0.1" +overlay0_desc0_overlay = img/dpad.png +overlay0_desc0_reach_x = 1.5 +overlay0_desc0_movable = true +overlay0_desc1 = "abxy_area,0.8,0.7,radial,0.12,0.08" +overlay0_desc1_up = "start" +overlay0_desc2 = "analog_left,0.2,0.3,radial,0.1,0.1" +overlay0_desc2_saturate_pct = 0.6 +overlay0_desc2_exclusive = true +]] +local area = assert(TouchSkin.parse(AREA_CFG)) +local ap = area.pages[1] +near(ap.aspect, 0.5625, "a portrait-named overlay defaults to 9:16") +check(not ap.aspectFromCfg, "and that default is not a cfg aspect lock") +eq(#ap.controls, 1 + 8 + 8 + 8, "each area desc expands into eight hitboxes") + +local art = ap.controls[1] +check(art.decorative, "the dpad_area art is carried by a decoration") +eq(art.imagePath, "img/dpad.png", "with the desc's own overlay image") +near(art.rangeX, 0.15, "sized like the area it replaces") + +local sectorE = ap.controls[2] +eq(sectorE.spec, "right", "the first sector is the one pointing right") +near(sectorE.x, 0.2, "every sector sits on the area centre") +near(sectorE.y, 0.7, "on both axes") +near(sectorE.rangeX, 0.15, "and covers the whole area, not a ninth of it") +near(sectorE.reachLeft, 1.5, "the desc reach_x rides onto the sectors as it is") +near(sectorE.reachRight, 1.5, "on both sides") +eq(sectorE.sector, 1, "the sector index is kept for the hit test") +eq(ap.controls[3].spec, "right|down", "the next sector is the lower-right corner") +eq(ap.controls[4].spec, "down", "then straight down, y growing downwards") +eq(ap.controls[8].spec, "up", "and straight up seven sectors along") +check(ap.controls[1].movable, "_movable is parsed") + +local abxy = ap.controls[10] +eq(abxy.spec, "a", "abxy right is RetroPad a, which is GB A") +eq(abxy.buttons[1], "a", "and reaches that GB button") +eq(ap.controls[16].spec, "start", "abxy_area honours an _up override") +eq(ap.controls[15].spec, "y|start", "the up-left sector combines both sides") +check(ap.controls[15].exclusive == nil, "and inherits nothing the desc did not set") +check(ap.controls[14].decorative, "RetroPad Y has no GB button, so that sector is inert") +eq(abxy.shape, "radial", "a radial area keeps its ellipse") + +eq(ap.controls[18].spec, "right", "analog_left degrades to a directional pad") +check(ap.controls[18].exclusive, "_exclusive rides onto the expanded sectors") +eq(ap.controls[23].spec, "left|up", "with all eight sectors") +near(ap.controls[18].rangeX, 0.1, "analog sectors share the whole stick area") + +local sq = { x = 0.5, y = 0.5, rangeX = 0.25, rangeY = 0.25, shape = "rect", + rangeMod = 1, alphaMod = 1, + reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1 } +local sectors = TouchSkin.expandSectors(sq, TouchSkin.AREA_DEFAULTS.dpad_area) +eq(#sectors, 8, "a dpad area expands into eight sector hitboxes") +local page = { rect = { x = 0, y = 0, w = 1, h = 1 }, fullScreen = true, + aspect = 1, controls = sectors } +local function hitSpecs(px, py) + local out = {} + for _, ctl in ipairs(sectors) do + if TouchSkin.hits(page, ctl, 100, 100, px, py, 0, 0) then out[#out + 1] = ctl.spec end + end + return table.concat(out, "+") +end +eq(hitSpecs(50, 50), "right", "the exact centre still fires a direction: no dead zone") +eq(hitSpecs(60, 50), "right", "a touch to the right of centre is right") +eq(hitSpecs(50, 60), "down", "a touch below centre is down, y growing downwards") +eq(hitSpecs(50, 40), "up", "a touch above centre is up") +eq(hitSpecs(40, 40), "left|up", "a diagonal touch fires both directions") +eq(hitSpecs(58, 52), "right", "17 degrees off the axis is still a pure direction") +eq(hitSpecs(55, 53), "right|down", "and 31 degrees is the diagonal, not a grid corner") +eq(hitSpecs(50, 80), "", "outside the area nothing fires") + +local PIXEL_NO_IMAGE = [[ +overlays = 1 +overlay0_name = "pixels" +overlay0_descs = 1 +overlay0_desc0 = "a,120,80,rect,20,10" +]] +local noImage = assert(TouchSkin.parse(PIXEL_NO_IMAGE)) +check(hasWarning(noImage, "no base image"), + "pixel coords without a base image are called out") +check(noImage.pages[1].pixelCoords == false, + "and read as normalized rather than dividing by nothing") + +love.filesystem.write("skins/px/overlay.cfg", [[ +overlays = 1 +overlay0_name = "px" +overlay0_overlay = img/base.png +overlay0_full_screen = true +overlay0_descs = 2 +overlay0_desc0 = "a,4,4,rect,2,1" +overlay0_desc1 = "b,6,2,rect,1,1" +overlay0_desc1_normalized = true +]]) +local px = assert(TouchSkin.load("skins/px", "px")) +local pxPage = px.pages[1] +check(pxPage.image ~= nil, "the base overlay image loads") +local iw, ih = pxPage.image:getDimensions() +near(pxPage.controls[1].x, 4 / iw, "pixel x is divided by the base image width") +near(pxPage.controls[1].y, 4 / ih, "pixel y is divided by the base image height") +near(pxPage.controls[1].rangeX, 2 / iw, "and so are the half extents") +near(pxPage.controls[2].x, 6, "a per-desc normalized flag opts that desc out") +check(pxPage.pixelCoords == false, "the page is normalized once converted") + +love.filesystem.write("skins/pxbad/overlay.cfg", [[ +overlays = 1 +overlay0_name = "pxbad" +overlay0_overlay = img/broken.png +overlay0_descs = 1 +overlay0_desc0 = "a,4,4,rect,2,1" +]]) +local savedNewImage = love.graphics.newImage +love.graphics.newImage = function() error("unreadable image") end +local badPx, badPxErr = TouchSkin.load("skins/pxbad", "pxbad") +love.graphics.newImage = savedNewImage +eq(badPx, nil, "a skin whose pixel coordinates have no base image fails to load") +check(tostring(badPxErr):find("img/broken.png", 1, true) ~= nil, + "and the error names the image it could not read") + +local DELTA_JSON = [[ +{ + "name": "Test GBC", + "identifier": "com.example.gbc.test", + "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "debug": false, + "representations": { + "iphone": { + "edgeToEdge": { + "portrait": { + "assets": { "small": "p_small.png", "medium": "p_medium.png", + "large": "p_large.png" }, + "items": [ + { "inputs": ["a"], "frame": {"x":240,"y":320,"width":64,"height":64}, + "mask": "circle" }, + { "inputs": ["b"], "frame": {"x":160,"y":360,"width":64,"height":64}, + "extendedEdges": {"right":16} }, + { "inputs": {"up":"up","down":"down","left":"left","right":"right"}, + "frame": {"x":16,"y":320,"width":96,"height":96} }, + { "inputs": ["start","select"], + "frame": {"x":128,"y":448,"width":64,"height":32} }, + { "inputs": ["menu"], "frame": {"x":0,"y":0,"width":32,"height":32} }, + { "inputs": ["quickSave"], + "frame": {"x":288,"y":0,"width":32,"height":32} } + ], + "mappingSize": {"width":320,"height":480}, + "extendedEdges": {"top":8,"bottom":8,"left":8,"right":8}, + "translucent": false, + "screens": [{ "inputFrame": {"x":0,"y":0,"width":160,"height":144}, + "outputFrame": {"x":0,"y":32,"width":320,"height":288} }] + } + }, + "standard": { + "portrait": { "items": [], "mappingSize": {"width":320,"height":480} } + } + } + } +} +]] + +local delta = assert(TouchSkin ~= nil and DeltaSkin.parse(DELTA_JSON)) +eq(delta.format, "delta", "a .deltaskin parses into the native model") +eq(delta.name, "Test GBC", "info.json name") +eq(delta.system, "gbc", "gbc covers both GB and GBC") +eq(#delta.pages, 1, "only the orientations present become pages") +local dp = delta.pages[1] +eq(dp.name, "portrait", "the page is named for its orientation") +eq(dp.orient, "portrait", "and locked to it") +eq(dp.imagePath, "p_large.png", "the PNG ladder picks the largest for a phone") +check(dp.fullScreen, "Delta stretches its skin over the whole surface") +check(not dp.aspectFromCfg, "so nothing letterboxes it") +near(dp.aspect, 320 / 480, "the page aspect is the mappingSize aspect") +eq(#dp.controls, 13, "edgeToEdge wins over standard, so all six items parsed") + +local dA = dp.controls[1] +eq(dA.buttons[1], "a", "an inputs array binds its button") +eq(dA.shape, "radial", 'mask "circle" becomes a radial hitbox') +near(dA.x, 0.85, "frame top-left plus half width is the native centre") +near(dA.y, 352 / 480, "and the same for y") +near(dA.rangeX, 0.1, "frame width halves into the native half extent") +near(dA.reachLeft, 1.25, "orientation extendedEdges become reach") + +local dB = dp.controls[2] +near(dB.reachRight, 1.5, "a per-item extendedEdges key overrides that side") +near(dB.reachLeft, 1.25, "and leaves the others inherited") + +eq(dp.controls[3].spec, "left|up", "a dpad input object expands to a 3x3 grid") +near(dp.controls[3].x, 0.1, "dpad top-left cell x") +near(dp.controls[3].rangeX, 0.05, "dpad cells are a third of the frame") +near(dp.controls[3].reachLeft, 1.5, "with the extended edge re-scaled onto them") +eq(dp.controls[4].spec, "up", "dpad top-centre cell") +eq(dp.controls[10].spec, "right|down", "dpad bottom-right cell") + +eq(dp.controls[11].spec, "start|select", "a multi-input item fires both") +eq(dp.controls[12].hotkeys[1], "menu", "the Delta menu button becomes a hotkey") +check(dp.controls[13].decorative, + "quickSave has no engine hotkey, so it is inert rather than a game button") + +check(dp.viewport ~= nil, "screens[] places the emulator picture") +near(dp.viewport.y, 32 / 480, "outputFrame y normalizes by mappingSize") +near(dp.viewport.h, 288 / 480, "outputFrame height normalizes by mappingSize") + +local bx, by, bw, bh = TouchSkin.pageBox(dp, 1000, 500) +eq(bx, 0, "delta page box x") eq(by, 0, "delta page box y") +eq(bw, 1000, "delta page box fills the width") +eq(bh, 500, "delta page box fills the height") + +local DECK_JSON = [[ +{ "name": "Deck", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "large": "deck.png" }, + "mappingSize": {"width":320,"height":240}, + "items": [ { "inputs": ["a"], "frame": {"x":240,"y":60,"width":64,"height":64} } ] + } } } } } +]] +local deck = assert(DeltaSkin.parse(DECK_JSON)) +local deckPage = deck.pages[1] +check(deckPage.aspectFromCfg, "a portrait deck without screens keeps mapping aspect") +eq(deckPage.anchor, "bottom", "and sits at the bottom of the window") +eq(deckPage.screenFit, "remainder", "with the leftover given to the GB picture") +eq(deckPage.viewport, nil, "no screens[] means no baked cutout") +local dbx, dby, dbw, dbh = TouchSkin.pageBox(deckPage, 1080, 1920) +eq(dbx, 0, "deck overlay is full width") +eq(dbw, 1080, "deck overlay width") +near(dbh, 1080 * 240 / 320, "deck overlay height is mapping aspect") +near(dby, 1920 - dbh, "pinned to the bottom, not stretched") +local vx, vy, vw, vh = TouchSkin.pageViewport(deckPage, 1080, 1920) +eq(vx, 0, "screen leftover x") eq(vy, 0, "screen leftover y") +eq(vw, 1080, "screen leftover is full width") +near(vh, dby, "and fills everything above the overlay") +check(vh > dbh, "there is more room for the picture than for the pad") + +local LEGACY_SCREEN = [[ +{ "gameTypeIdentifier": "public.aoshuang.game.gbc", + "representations": { "iphone": { "standard": { "landscape": { + "mappingSize": {"width":640,"height":320}, + "gameScreenFrame": {"x":160,"y":0,"width":320,"height":288}, + "translucent": true, + "items": [ { "inputs": {"up":"analogStickUp","down":"analogStickDown", + "left":"analogStickLeft","right":"analogStickRight"}, + "frame": {"x":0,"y":0,"width":120,"height":120} } ] } } } } } +]] +local legacy = assert(DeltaSkin.parse(LEGACY_SCREEN)) +eq(legacy.system, "gbc", "the Manic public.aoshuang prefix is accepted") +eq(#legacy.pages, 1, "landscape only") +eq(legacy.pages[1].orient, "landscape", "orientation key drives the lock") +near(legacy.pages[1].viewport.x, 0.25, "gameScreenFrame is the legacy screen rect") +near(legacy.pages[1].alphaMod, 0.7, "translucent dims the controls") +eq(#legacy.pages[1].controls, 8, "a thumbstick degrades to a directional pad") +eq(legacy.pages[1].controls[1].spec, "left|up", "with the analog names mapped") + +local snes = assert(DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.delta.game.snes", + "representations": { "iphone": { "standard": { "portrait": { + "mappingSize": {"width":320,"height":480}, "items": [] } } } } } +]])) +check(hasWarning(snes, "not Game Boy"), "a non Game Boy skin warns") +eq(#snes.pages, 1, "but still imports") + +eq(select(1, DeltaSkin.parse([[ +{ "name": "old", "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gba", + "representations": { "iphone": { "portrait": { "assets": {} } } } } +]])), nil, "a GBA4iOS skin is refused") +local _, gbaErr = DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gbc", "representations": {} } +]]) +check(tostring(gbaErr):find("GBA4iOS", 1, true) ~= nil, + "and the message names the old format") + +local _, noTypeErr = DeltaSkin.parse('{ "representations": {} }') +check(tostring(noTypeErr):find("gameTypeIdentifier", 1, true) ~= nil, + "info.json without a gameTypeIdentifier is refused by name") +eq(select(1, DeltaSkin.parse("not json at all")), nil, "garbage is refused") +eq(select(1, DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", "representations": {} } +]])), nil, "an empty representations tree is refused") + +local PDF_JSON = [[ +{ "name": "Vector", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "resizable": "iphone_portrait.pdf" }, + "mappingSize": {"width":320,"height":480}, + "items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":32,"height":32} } ] + } } } } } +]] +local pdf = assert(DeltaSkin.parse(PDF_JSON)) +eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art") +eq(pdf.pages[1].pdfPath, "iphone_portrait.pdf", + "but the PDF path is kept so load can extract a JPEG from it") +local convert = DeltaSkin.needsConversion(pdf) +check(convert ~= nil, "PDF-only skins report that they need conversion") +if convert then + check(convert.pdfOnly, "the report is flagged pdfOnly") + eq(convert.files[1], "iphone_portrait.pdf", "and names the file to convert") +end +eq(DeltaSkin.needsConversion(delta), nil, "a PNG skin needs no conversion") + +local mixed = assert(DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.delta.game.gb", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "resizable": "art.pdf", "medium": "art.png" }, + "mappingSize": {"width":320,"height":480}, "items": [] } } } } } +]])) +eq(mixed.pages[1].imagePath, "art.png", "a raster asset beats the PDF") +eq(DeltaSkin.needsConversion(mixed), nil, "so no conversion is needed") + +eq(DeltaSkin.pickAsset({ small = "s.png" }, { targetWidth = 1080 }, {}), "s.png", + "the ladder falls back to the largest shipped asset") +eq(DeltaSkin.pickAsset({ small = "s.png", medium = "m.png", large = "l.png" }, + { targetWidth = 640 }, {}), "s.png", + "a small target takes the small asset") +eq(DeltaSkin.pickAsset({ normal = "n.png" }, { targetWidth = 640 }, {}), "n.png", + 'the Manic "normal" alias is accepted') + +love.filesystem.write("skins/wrapped.deltaskin/MySkin/info.json", [[ +{ "name": "Wrapped", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "large": "Portrait.PNG" }, + "mappingSize": {"width":320,"height":480}, + "items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":64,"height":64} } ] + } } } } } +]]) +love.filesystem.write("skins/wrapped.deltaskin/MySkin/portrait.png", "\137PNG\r\n\26\n") + +local wrappedId, wrappedErr = TouchSkin.installArchive("wrapped.deltaskin", "PK\3\4stub") +eq(wrappedId, "wrapped", "a .deltaskin installs under its bare name: " .. tostring(wrappedErr)) +local wrapped = assert(TouchSkin.load("skins/_mounted/wrapped", "wrapped")) +eq(wrapped.format, "delta", "the mounted archive is recognised as a Delta skin") +eq(wrapped.name, "Wrapped", "and its name comes from info.json") +eq(wrapped.pages[1].imagePath, "MySkin/portrait.png", + "the wrapping folder is prefixed onto assets and the real file name wins") +eq(#wrapped.pages[1].controls, 1, "the wrapped items parsed") + +love.filesystem.write("skins/vector.deltaskin/info.json", PDF_JSON) +local vectorId, vectorErr = TouchSkin.installArchive("vector.deltaskin", "PK\3\4stub") +eq(vectorId, nil, "a PDF-only skin is refused instead of installing invisible") +check(tostring(vectorErr):find("PDF artwork", 1, true) ~= nil, + "with the message that asks for a PNG version") +eq(love.filesystem.read("skins/vector.deltaskin"), nil, + "and the refused archive is not left behind") + +local PdfImage = require("src.core.PdfImage") +local function unhex(s) + return (s:gsub("..", function(cc) + return string.char(tonumber(cc, 16)) + end)) +end +-- 1x1 JFIF JPEG, so extract tests do not need a file on disk. +local TINY_JPEG = unhex( + "ffd8ffe000104a46494600010100000100010000ffdb0043000806060706050807070709" .. + "09080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c283729" .. + "2c30313434341f27393d38323c2e333432ffc0000b080001000101011100ffc400140001" .. + "0000000000000000000000000000000008ffc40014100100000000000000000000000000" .. + "00000000ffda0008010100003f007f3fffd9") +local function jpegPdf(jpeg, w, h) + return "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width " + .. tostring(w) .. " /Height " .. tostring(h) + .. " /BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter /DCTDecode /Length " + .. tostring(#jpeg) .. " >>\nstream\n" .. jpeg .. "\nendstream\nendobj\n%%EOF\n" +end +local extracted = assert(PdfImage.extract(jpegPdf(TINY_JPEG, 1, 1))) +eq(extracted.ext, "jpg", "a JPEG-in-PDF yields a jpg") +eq(extracted.data, TINY_JPEG, "and the JPEG body is recovered byte for byte") +eq(extracted.width, 1, "width comes from the Image XObject") +eq(extracted.height, 1, "and so does height") + +local indirect = "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width 1" + .. " /Height 1 /Filter /DCTDecode /Length 5 0 R >>\nstream\n" .. TINY_JPEG + .. "\nendstream\nendobj\n5 0 obj\n" .. tostring(#TINY_JPEG) .. "\nendobj\n%%EOF\n" +local fromRef = assert(PdfImage.extract(indirect)) +eq(fromRef.data, TINY_JPEG, + "an indirect /Length (the 3-Heights Image-to-PDF layout) still extracts") + +eq(select(1, PdfImage.extract("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")), + nil, "a vector PDF with no image is not pretended to be art") +eq(select(1, PdfImage.extract("not a pdf")), nil, "and neither is garbage") + +love.filesystem.write("skins/pikapdf.deltaskin/info.json", PDF_JSON) +love.filesystem.write("skins/pikapdf.deltaskin/iphone_portrait.pdf", + jpegPdf(TINY_JPEG, 1, 1)) +local pikaId, pikaErr = TouchSkin.installArchive("pikapdf.deltaskin", "PK\3\4stub") +eq(pikaId, "pikapdf", "a Delta skin whose PDF wraps a JPEG installs: " + .. tostring(pikaErr)) +local pika = assert(TouchSkin.load("skins/_mounted/pikapdf", "pikapdf")) +check(pika.pages[1].rasterData == TINY_JPEG, + "load recovers the JPEG from the PDF") +check(pika.pages[1].image ~= nil, "and LOVE gets an image from those bytes") +eq(DeltaSkin.needsConversion(pika), nil, + "so the skin no longer reports that it needs conversion") + +eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil, + "a GBA4iOS .gbcskin is refused at the door") +local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub") +check(tostring(legacyErr):find("GBA4iOS", 1, true) ~= nil, + "with a message that names the format") +eq(select(1, TouchSkin.installArchive("skin.rar", "PK\3\4stub")), nil, + "an unknown archive extension is refused") +eq(TouchSkin.archiveId("pad.deltaskin"), "pad", "archiveId strips .deltaskin") +eq(TouchSkin.archiveId("pad.zip"), "pad", "archiveId strips .zip") +eq(TouchSkin.archiveId("pad"), nil, "a bare name is not an archive") + +local AUTHORED = [[ +return { name = "Authored", pages = { + { name = "portrait", orient = "portrait", fullScreen = true, + viewport = { x = 0, y = 0, w = 1, h = 0.5 }, + controls = { + { bind = "a", x = 0.8, y = 0.75, w = 0.2, h = 0.1, shape = "radial" }, + { bind = "b", x = 0.6, y = 0.8, w = 0.2, h = 0.1, shape = "radial", + reachRight = 1.5 }, + { bind = "start", x = 0.5, y = 0.95, w = 0.1, h = 0.04 }, + { bind = "menu_toggle", x = 0.05, y = 0.05, w = 0.08, h = 0.04 }, + { bind = "nul", x = 0.2, y = 0.7, w = 0.3, h = 0.2, image = "img/dpad.png" }, + } }, + { name = "landscape", orient = "landscape", fullScreen = true, + controls = { + { bind = "a", x = 0.9, y = 0.8, w = 0.1, h = 0.15, shape = "radial" }, + } }, +} } +]] +local authored = assert(TouchSkin.parseNative(AUTHORED)) +authored.id = "authored" +authored.root = "skins/authored" + +local cfgText = TouchSkin.toRetroArchConfig(authored) +check(cfgText:find("overlays = 2", 1, true) ~= nil, "the cfg declares its overlays") +local reparsed = assert(TouchSkin.parse(cfgText)) +eq(#reparsed.pages, 2, "the generated cfg round-trips both pages") +eq(reparsed.pages[1].name, "portrait", "and their names") +eq(#reparsed.pages[1].controls, 5, "and every desc") +eq(reparsed.pages[1].controls[1].spec, "a", "binds survive the round trip") +near(reparsed.pages[1].controls[1].x, 0.8, "centres survive the round trip") +near(reparsed.pages[1].controls[1].rangeX, 0.1, "half extents survive") +eq(reparsed.pages[1].controls[1].shape, "radial", "hitbox shape survives") +near(reparsed.pages[1].controls[2].reachRight, 1.5, "per-side reach survives") +eq(reparsed.pages[1].controls[4].hotkeys[1], "menu", "hotkeys survive") +check(reparsed.pages[1].controls[5].decorative, "decoration stays decoration") +eq(reparsed.pages[1].controls[5].imagePath, "img/dpad.png", "and keeps its art") +near(reparsed.pages[1].viewport.h, 0.5, "the screen cutout survives") +eq(reparsed.pages[1].orient, "portrait", "the orientation lock survives by name") + +local KEY_SKIN = [[ +return { name = "Keys", pages = { + { name = "portrait", fullScreen = true, controls = { + { bind = "key:escape", x = 0.5, y = 0.5, w = 0.1, h = 0.1 }, + } }, +} } +]] +local keySkin = assert(TouchSkin.parseNative(KEY_SKIN)) +local keyCfg = TouchSkin.toRetroArchConfig(keySkin) +check(keyCfg:find("retrok_escape", 1, true) ~= nil, + "a key bind exports in the grammar RetroArch understands") +check(keyCfg:find("key:escape", 1, true) == nil, "and not in the native spelling") +eq(assert(TouchSkin.parse(keyCfg)).pages[1].controls[1].keys[1], "escape", + "which this importer still reads back as the same key") + +local areaCfg = TouchSkin.toRetroArchConfig({ pages = area.pages }) +local areaBack = assert(TouchSkin.parse(areaCfg)) +eq(#areaBack.pages[1].controls, #ap.controls, + "an area desc exports as one desc, not eight overlapping ones") +check(areaCfg:find("dpad_area", 1, true) ~= nil, "the area kind is written back") +check(areaCfg:find('_up = "start"', 1, true) ~= nil, "with its output override") +eq(areaBack.pages[1].controls[16].spec, "start", "which survives the round trip") + +local areaInfo = assert(DeltaSkin.build({ id = "area", pages = area.pages })) +local areaRep = areaInfo.representations.iphone.edgeToEdge.portrait +local dpadItem +for _, item in ipairs(areaRep.items) do + if not dpadItem and type(item.inputs) == "table" and item.inputs.up then + dpadItem = item + end +end +check(dpadItem ~= nil, "the same area exports to Delta as one d-pad item") +eq(dpadItem.inputs.left, "left", "carrying each direction") +eq(#areaRep.items, 3, "one per area desc, not eight stacked on one another") + +local raPath = os.tmpname() .. "-ra.zip" +local raWritten, raMissing = TouchSkin.exportRetroArch(authored, raPath) +eq(raWritten, raPath, "exportRetroArch writes where it was told") +eq(raMissing[1], "img/dpad.png", "and reports art it could not find") +local raZip = unzip(readBytes(raPath)) +eq(raZip[1], "overlay.cfg", "the RetroArch zip leads with overlay.cfg") +check(raZip["overlay.cfg"] ~= nil, "and the entry has bytes") +check(TouchSkin.parse(raZip["overlay.cfg"]) ~= nil, "which RetroArch grammar accepts") +os.remove(raPath) + +local dsPath = os.tmpname() .. ".deltaskin" +local dsWritten, _, dsWarnings = TouchSkin.exportDelta(authored, { path = dsPath }) +eq(dsWritten, dsPath, "exportDelta writes where it was told") +check(#dsWarnings > 0, "and warns that per-button art has nowhere to go") +local dsZip = unzip(readBytes(dsPath)) +eq(dsZip[1], "info.json", "the .deltaskin leads with info.json") +local info = assert(Json.decode(dsZip["info.json"])) +eq(info.gameTypeIdentifier, "com.rileytestut.delta.game.gbc", + "the export claims the GBC game type") +eq(info.name, "Authored", "and carries the skin name") +check(info.identifier:find("authored", 1, true) ~= nil, "identifier names the skin") +local rep = info.representations.iphone.edgeToEdge.portrait +check(rep ~= nil, "an iPhone edgeToEdge portrait representation is emitted") +eq(info.representations.iphone.standard.portrait.mappingSize.width, 1080, + "standard portrait maps 1080 wide") +eq(rep.mappingSize.height, 1920, "portrait maps 1920 tall") +eq(#rep.items, 4, "only bound controls become Delta items") +eq(rep.items[1].inputs[1], "a", "the first item is A") +eq(rep.items[1].mask, "circle", "a radial hitbox exports as a circle mask") +eq(rep.items[1].frame.x, 756, "frame x is top-left, not centre") +eq(rep.items[1].frame.width, 216, "frame width is the full extent") +eq(rep.items[2].extendedEdges.right, 54, "reach exports as extendedEdges") +eq(rep.items[4].inputs[1], "menu", "the menu hotkey exports as a Delta host input") +eq(rep.screens[1].inputFrame.width, 160, "the screen crop is a full GB frame") +eq(rep.screens[1].outputFrame.height, 960, "and the output frame follows the viewport") +eq(info.representations.iphone.edgeToEdge.landscape.mappingSize.width, 1920, + "the landscape page maps 1920 wide") + +local back = assert(DeltaSkin.parse(dsZip["info.json"])) +eq(#back.pages, 2, "the exported skin re-imports both orientations") +local bp = back.pages[1] +eq(#bp.controls, 4, "with every bound control") +near(bp.controls[1].x, 0.8, "and the same centres it started with") +near(bp.controls[1].rangeX, 0.1, "and the same half extents") +eq(bp.controls[1].shape, "radial", "and the same hitbox shape") +near(bp.controls[2].reachRight, 1.5, "and the same reach") +near(bp.viewport.h, 0.5, "and the same screen cutout") +os.remove(dsPath) + +love.filesystem.write("skins/collide/overlay.cfg", [[ +overlays = 1 +overlay0_name = "collide" +overlay0_descs = 1 +overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05" +]]) +local collide = assert(TouchSkin.load("skins/collide", "collide")) +local defaultDelta = assert(TouchSkin.exportDelta(collide)) +eq(defaultDelta, "skins/_export/collide.deltaskin", + "a default export lands outside the folder the skin list scans") +local listedRoot, listedExport +for _, entry in ipairs(TouchSkin.list()) do + if entry.id == "collide" then listedRoot = entry.root end + if entry.id == "_export" then listedExport = true end +end +eq(listedRoot, "skins/collide", "so the export cannot shadow the skin it came from") +check(not listedExport, "and the export folder is not a skin of its own") + +T.finish("skin_format_import") diff --git a/tests/engine/touch_skin_dpad_area.lua b/tests/engine/touch_skin_dpad_area.lua new file mode 100644 index 000000000..abb10e198 --- /dev/null +++ b/tests/engine/touch_skin_dpad_area.lua @@ -0,0 +1,201 @@ +-- From Gen1Recomp 535a5ec0, Copyright (C) 2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section 7; see licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- RetroArch dpad_area / abxy_area descs (#1533): one hitbox whose fired +-- input is resolved by the angle of the touch from the area centre, and +-- range_mod growing a hitbox only while it is held. The cfg below is the +-- reporter's GBA skin, trimmed to the d-pad and face buttons. +-- luajit tests/engine/touch_skin_dpad_area.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local TouchControls = require("src.core.TouchControls") +local Input = require("src.core.Input") + +local CFG = [[ +overlays = 2 + +overlay0_name = "portrait" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_range_mod = 1.5 +overlay0_alpha_mod = 1 +overlay0_aspect_ratio = 0.45 +overlay0_descs = 13 +overlay0_desc0 = "select,0.41944,0.58793,radial,0.06667,0.03" +overlay0_desc0_overlay = p-btn-select.png +overlay0_desc0_reach_x = 1.25 +overlay0_desc0_reach_y = 1.25 +overlay0_desc1 = "start,0.58056,0.58793,radial,0.06667,0.03" +overlay0_desc1_overlay = p-btn-start.png +overlay0_desc1_reach_x = 1.25 +overlay0_desc1_reach_y = 1.25 +overlay0_desc2 = "up,0.25,0.6625,radial,0.07778,0.035" +overlay0_desc2_overlay = p-btn-dpad-up.png +overlay0_desc2_reach_x = 0 +overlay0_desc3 = "left,0.12222,0.72,radial,0.07778,0.035" +overlay0_desc3_overlay = p-btn-dpad-left.png +overlay0_desc3_reach_x = 0 +overlay0_desc4 = "right,0.37778,0.72,radial,0.07778,0.035" +overlay0_desc4_overlay = p-btn-dpad-right.png +overlay0_desc4_reach_x = 0 +overlay0_desc5 = "down,0.25,0.7775,radial,0.07778,0.035" +overlay0_desc5_overlay = p-btn-dpad-down.png +overlay0_desc5_reach_x = 0 +overlay0_desc6 = "up|left,0.11644,0.6599,radial,0.01667,0.0075" +overlay0_desc6_overlay = p-btn-corner.png +overlay0_desc6_reach_x = 0 +overlay0_desc7 = "up|right,0.38356,0.6599,radial,0.01667,0.0075" +overlay0_desc7_overlay = p-btn-corner.png +overlay0_desc7_reach_x = 0 +overlay0_desc8 = "down|left,0.11644,0.7801,radial,0.01667,0.0075" +overlay0_desc8_overlay = p-btn-corner.png +overlay0_desc8_reach_x = 0 +overlay0_desc9 = "down|right,0.38356,0.7801,radial,0.01667,0.0075" +overlay0_desc9_overlay = p-btn-corner.png +overlay0_desc9_reach_x = 0 +overlay0_desc10 = "dpad_area,0.25,0.72,radial,0.22778,0.1025" +overlay0_desc10_overlay = p-area-dpad.png +overlay0_desc10_reach_x = 1.25 +overlay0_desc10_reach_y = 1.25 +overlay0_desc11 = "a,0.84382,0.69563,radial,0.09722,0.04375" +overlay0_desc11_overlay = p-btn-act2-a.png +overlay0_desc11_reach_x = 1.25 +overlay0_desc11_reach_y = 1.25 +overlay0_desc12 = "b,0.65618,0.74438,radial,0.09722,0.04375" +overlay0_desc12_overlay = p-btn-act2-b.png +overlay0_desc12_reach_x = 1.25 +overlay0_desc12_reach_y = 1.25 + +overlay1_name = "areas" +overlay1_full_screen = true +overlay1_normalized = true +overlay1_descs = 2 +overlay1_desc0 = "dpad_area,0.25,0.5,rect,0.2,0.2" +overlay1_desc0_up = "start" +overlay1_desc0_down = "select" +overlay1_desc0_left = "nul" +overlay1_desc1 = "abxy_area,0.75,0.5,rect,0.2,0.2" +]] + +local skin = assert(TouchSkin.parse(CFG)) +local page = skin.pages[1] + +eq(#page.controls, 12 + 1 + 8, "the dpad_area expands into eight sector controls") +local sectors = {} +for _, ctl in ipairs(page.controls) do + if ctl.sector then sectors[ctl.sector] = ctl end +end +eq(#sectors, 8, "eight sectors, one per direction") +eq(sectors[1].spec, "right", "sector 1 is right") +eq(sectors[2].spec, "right|down", "sector 2 is the down-right diagonal") +eq(sectors[3].spec, "down", "sector 3 is down, y growing downwards") +eq(sectors[7].spec, "up", "sector 7 is up") +eq(sectors[1].x, 0.25, "every sector keeps the area centre") +eq(sectors[5].y, 0.72, "on both axes") +eq(sectors[3].rangeX, 0.22778, "and the whole area range") +eq(sectors[3].shape, "radial", "and the declared hitbox shape") + +local art = page.controls[11] +eq(art.imagePath, "p-area-dpad.png", "the area art rides a decorative desc") +check(art.decorative, "which presses nothing") + +TouchControls:init() +TouchControls.active = true +TouchControls.enabled = true +TouchSkin.setOverlayLive(true) +TouchSkin.setActive(skin) +Input:init() + +local W, H = 720, 1600 +TouchSkin.setSurface(0, 0, W, H) +eq(TouchSkin.page().name, "portrait", "the portrait page is live at 720x1600") + +local BUTTONS = { "up", "down", "left", "right", "a", "b", "start", "select" } +local function heldNow() + local out = {} + for _, btn in ipairs(BUTTONS) do + if Input:isDown(btn) then out[#out + 1] = btn end + end + return table.concat(out, "+") +end + +local function press(nx, ny) + TouchControls:touchpressed("f1", nx * W, ny * H) + local got = heldNow() + TouchControls:touchreleased("f1", nx * W, ny * H) + return got +end + +local function fires(nx, ny, want, why) + eq(press(nx, ny), want, why) +end + +fires(0.25, 0.6625, "up", "the d-pad up arrow fires up alone") +fires(0.12222, 0.72, "left", "the left arrow fires left alone") +fires(0.37778, 0.72, "right", "the right arrow fires right alone") +fires(0.25, 0.7775, "down", "the down arrow fires down alone") +fires(0.11644, 0.6599, "up+left", "the up-left corner fires both, and only both") +fires(0.38356, 0.7801, "down+right", "as does the down-right corner") + +fires(0.32, 0.77, "down+right", + "a spot inside the area but off every arrow resolves by angle: " + .. "(50.4, 80) pixels out is 57.8 degrees, the down-right sector") + +fires(0.84382, 0.69563, "a", "A fires alone") +fires(0.65618, 0.74438, "b", "B fires alone: the 1.5x range_mod does not grow the resting d-pad area over it") +fires(0.58056, 0.58793, "start", "START fires alone") +fires(0.41944, 0.58793, "select", "SELECT fires alone, with no phantom direction") +fires(0.5, 0.3, "", "the screen area presses nothing") + +TouchControls:touchpressed("f2", 0.25 * W, 0.6625 * H) +eq(heldNow(), "up", "slide starts on up") +TouchControls:touchmoved("f2", 0.37778 * W, 0.72 * H) +eq(heldNow(), "right", "sliding across the area swaps direction") +TouchControls:touchmoved("f2", 0.38356 * W, 0.7801 * H) +eq(heldNow(), "down+right", "and picks up the diagonal") +TouchControls:touchmoved("f2", 0.5 * W, 0.3 * H) +eq(heldNow(), "", "sliding out of the area releases it") +TouchControls:touchreleased("f2", 0.5 * W, 0.3 * H) + +local area = sectors[1] +local bx = 0.65618 * W +local by = 0.74438 * H +check(not TouchSkin.hits(page, area, W, H, bx, by, 0, 0, false), + "at rest the area hitbox stops short of B") +check(TouchSkin.hits(page, area, W, H, bx, by, 0, 0, true), + "a held area grows over B so the finger keeps its direction") +TouchControls:touchpressed("f3", 0.37778 * W, 0.72 * H) +TouchControls:touchmoved("f3", bx, by) +eq(heldNow(), "right+b", "sliding from the held area onto B keeps right held") +TouchControls:touchreleased("f3", bx, by) +eq(heldNow(), "", "and lifting clears both") + +TouchSkin.autoOrient = false +TouchSkin.setPage("areas") +eq(TouchSkin.page().name, "areas", "second page is live") + +W, H = 1000, 1000 +TouchSkin.setSurface(0, 0, W, H) + +fires(0.25, 0.35, "start", "_up rebinds the up sector of a dpad_area") +fires(0.25, 0.65, "select", "_down rebinds the down sector") +fires(0.12, 0.5, "", "_left = nul makes that sector inert") +fires(0.38, 0.5, "right", "an unset side keeps the d-pad default") + +fires(0.88, 0.5, "a", "abxy_area right is GB A") +fires(0.75, 0.62, "b", "abxy_area down is GB B") +fires(0.88, 0.62, "a+b", "the down-right sector fires both") +fires(0.75, 0.38, "", "RetroPad X has no GB button, so up is inert") +fires(0.94, 0.68, "a+b", "a rect area still hits inside its corner") +fires(0.75, 0.75, "", "and nothing past its edge") + +TouchSkin.setSurface(nil) +TouchSkin.setActive(nil) +TouchSkin.autoOrient = true + +T.finish("touch_skin_dpad_area") diff --git a/tests/engine/touch_skin_test.lua b/tests/engine/touch_skin_test.lua new file mode 100644 index 000000000..c2b75ad1b --- /dev/null +++ b/tests/engine/touch_skin_test.lua @@ -0,0 +1,401 @@ +-- From Gen1Recomp 535a5ec0, Copyright (C) 2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section 7; see licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- RetroArch-format touch skins (#1386): .cfg parsing, hitboxes, the press / +-- hotkey path through TouchControls, and the screen viewport the renderer +-- fits the Game Boy picture into. +-- luajit tests/engine/touch_skin_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local TouchControls = require("src.core.TouchControls") +local Input = require("src.core.Input") + +local CFG = [[ +overlays = 2 + +overlay0_name = "shell" +overlay0_overlay = img/back.png +overlay0_full_screen = true +overlay0_normalized = true +overlay0_alpha_mod = 0.001 +overlay0_range_mod = 1.5 +overlay0_viewport = "0.0,0.0,1.0,0.5" +overlay0_viewport_fill = true + +overlay0_descs = 7 +overlay0_desc0 = "a,0.80,0.75,radial,0.10,0.05" +overlay0_desc0_overlay = img/a.png +overlay0_desc1 = "left|down,0.20,0.90,rect,0.05,0.05" +overlay0_desc2 = "nul,0.20,0.75,rect,0.15,0.10" +overlay0_desc2_overlay = img/dpad.png +overlay0_desc3 = "overlay_next,0.95,0.55,radial,0.03,0.02" +overlay0_desc3_next_target = "second" +overlay0_desc4 = "hold_fast_forward,0.05,0.55,radial,0.03,0.02" +overlay0_desc5 = "reset,0.50,0.98,rect,0.04,0.02" +overlay0_desc6 = "key:f5,0.10,0.98,rect,0.04,0.02" + +overlay1_name = "second" +overlay1_full_screen = true +overlay1_normalized = true +overlay1_descs = 1 +overlay1_desc0 = "start,0.50,0.50,rect,0.10,0.10" +]] + +local skin = assert(TouchSkin.parse(CFG)) +eq(#skin.pages, 2, "two overlay pages parsed") + +local page = skin.pages[1] +eq(page.name, "shell", "page name") +eq(page.imagePath, "img/back.png", "page background path") +check(page.fullScreen, "full_screen parsed") +check(not page.aspectFromCfg, "a cfg without aspect_ratio does not lock aspect") +eq(page.alphaMod, 0.001, "overlay alpha_mod parsed") +eq(#page.controls, 7, "seven descs parsed") + +check(page.viewport ~= nil, "viewport parsed") +eq(page.viewport.h, 0.5, "viewport height") +check(page.viewportFill, "viewport_fill parsed") + +local a, diag, decor, nextBtn, ff, reset, keyBtn = unpack(page.controls) +eq(a.buttons[1], "a", "desc0 binds GB a") +eq(a.shape, "radial", "desc0 is radial") +eq(a.alphaMod, 0.001, "desc inherits the overlay alpha_mod") +eq(a.rangeMod, 1.5, "desc inherits the overlay range_mod") +eq(table.concat(diag.buttons, "+"), "left+down", "pipe-separated binds are one control") +check(decor.decorative, "nul desc is decoration only") +eq(decor.imagePath, "img/dpad.png", "decoration still carries art") +eq(nextBtn.hotkeys[1], "overlay_next", "overlay_next mapped") +eq(nextBtn.nextTarget, "second", "next_target parsed") +eq(ff.hotkeys[1], "fast_forward_hold", "hold_fast_forward mapped") +eq(reset.hotkeys[1], "soft_reset", "reset mapped") +eq(keyBtn.keys[1], "f5", "key: binds a keyboard key") + +-- geometry: x/y are the centre, range_x/range_y are half extents, both +-- normalized to the window when full_screen is set +local W, H = 400, 800 +local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, a, W, H) +eq(cx, 320, "control centre x") +eq(cy, 600, "control centre y") +eq(halfW, 40, "control half width") +eq(halfH, 40, "control half height") + +-- range_mod 1.5 widens the hitbox but not the art +check(TouchSkin.hits(page, a, W, H, 320, 600), "centre hits") +check(TouchSkin.hits(page, a, W, H, 320 + 55, 600), "inside range_mod hits") +check(not TouchSkin.hits(page, a, W, H, 320 + 65, 600), "past range_mod misses") +-- radial, so the corner of the bounding box is outside +check(not TouchSkin.hits(page, a, W, H, 320 + 55, 600 + 55), "radial corner misses") +-- rect hitbox does take its corner +check(TouchSkin.hits(page, diag, W, H, 80 + 19, 720 + 19), "rect corner hits") + +-- ---------------------------------------------------------------- runtime + +TouchControls:init() +TouchControls.active = true +TouchControls.enabled = true +TouchSkin.setOverlayLive(true) +TouchSkin.setActive(skin) +Input:init() + +local ww, wh = love.graphics.getDimensions() +local function at(nx, ny) return nx * ww, ny * wh end + +check(TouchSkin.hasViewport(), "active skin reports a viewport") + +-- the renderer fits the Game Boy picture into this rect instead of the window +local sx, sy, sw, sh, fill = TouchSkin.viewport(W, H) +eq(sx, 0, "viewport x") eq(sy, 0, "viewport y") +eq(sw, 400, "viewport w") eq(sh, 400, "viewport h") +check(fill, "viewport fill flag returned") +eq(TouchSkin.viewport(W, H), 0, "without a locked aspect, viewport tracks the window") + +check(TouchControls:touchpressed("f1", at(0.80, 0.75)), "press on A is captured") +check(Input:isDown("a"), "skin A presses GB a") +TouchControls:touchreleased("f1", at(0.80, 0.75)) +check(not Input:isDown("a"), "lifting releases GB a") + +-- one finger inside a combined desc holds both directions +TouchControls:touchpressed("f2", at(0.20, 0.90)) +check(Input:isDown("left") and Input:isDown("down"), "combined desc holds both buttons") +-- sliding onto the decoration drops them without capturing anything new +TouchControls:touchmoved("f2", at(0.20, 0.75)) +check(not Input:isDown("left") and not Input:isDown("down"), + "sliding off a control releases it") +TouchControls:touchreleased("f2", at(0.20, 0.75)) + +check(TouchControls:touchpressed("f3", at(0.20, 0.75)) == nil, + "a press on decoration alone is not captured") + +-- hotkeys reach the host once per press edge and once per release +local fired = {} +TouchControls:setHotkeyHandler(function(action, pressed) + fired[#fired + 1] = action .. ":" .. tostring(pressed) +end) +TouchControls:touchpressed("f4", at(0.05, 0.55)) +eq(fired[1], "fast_forward_hold:true", "held hotkey fires on press") +TouchControls:touchreleased("f4", at(0.05, 0.55)) +eq(fired[2], "fast_forward_hold:false", "held hotkey fires on release") + +-- a stranded finger unwinds through reset, not just through touchreleased +fired = {} +TouchControls:touchpressed("f5", at(0.05, 0.55)) +TouchControls:touchpressed("f6", at(0.80, 0.75)) +check(Input:isDown("a"), "second finger holds A") +TouchControls:reset() +eq(fired[2], "fast_forward_hold:false", "reset releases held hotkeys") +check(not Input:isDown("a"), "reset releases held buttons") + +-- overlay_next swaps the page, and the new page's controls are what hit +eq(TouchSkin.page().name, "shell", "starts on page 1") +TouchControls:touchpressed("f7", at(0.95, 0.55)) +eq(TouchSkin.page().name, "second", "overlay_next honours next_target") +TouchControls:touchreleased("f7", at(0.95, 0.55)) +TouchControls:touchpressed("f8", at(0.50, 0.50)) +check(Input:isDown("start"), "page 2 control presses GB start") +TouchControls:touchreleased("f8", at(0.50, 0.50)) +TouchSkin.setPage("shell") + +-- ------------------------------------------------------------ persistence + +local cfg = TouchControls.normalizeConfig({ enabled = true, skin = "gb_anim" }) +eq(cfg.skin, "gb_anim", "normalizeConfig keeps the skin id") +eq(TouchControls.normalizeConfig({ skin = "" }).skin, nil, "empty skin id drops") +eq(TouchControls.normalizeConfig({ skin = 7 }).skin, nil, "non-string skin id drops") + +TouchControls.skinId = "gb_anim" +eq(TouchControls:config().skin, "gb_anim", "config() round-trips the skin id") + +-- ----------------------------------------------- screen-hole detection + +-- Border art usually ships with a transparent screen hole and no viewport +-- key. Detection is verified against real art by +-- tests/drivers/tv_skin_shot.lua; here it only has to fail safely. +eq(TouchSkin.detectViewport("assets/skins/tv_crt", nil), nil, + "no bezel path detects nothing") +eq(TouchSkin.detectViewport("assets/skins/tv_crt", ""), nil, + "empty bezel path detects nothing") +eq(TouchSkin.detectViewport("assets/skins/tv_crt", "img/does_not_exist.png"), nil, + "a missing bezel detects nothing") + +-- Bundled third-party artwork is not shipped by this engine; asset loading and +-- export are covered with owned in-memory fixtures in touch_skin_native.lua. + +-- ------------------------------------------- decorative desktop bezels + +-- A skin whose page binds nothing is a frame, not a pad: it draws where the +-- touch overlay does not (desktop), and a gamepad must not hide it. +local BEZEL = [[ +overlays = 1 +overlay0_name = "tv" +overlay0_overlay = img/tv.png +overlay0_full_screen = true +overlay0_normalized = true +overlay0_descs = 1 +overlay0_desc0 = "nul,0.5,0.5,rect,0.5,0.5" +overlay0_viewport = "0.2,0.1,0.6,0.8" +]] +local bezel = assert(TouchSkin.parse(BEZEL)) +TouchSkin.setActive(bezel) + +TouchSkin.setOverlayLive(false) +check(TouchSkin.decorativeOnly(), "a descs-only-nul skin is decoration") +check(TouchSkin.drawable(), "a decorative skin draws with the overlay off (desktop)") +check(TouchSkin.hasViewport(), "and its viewport still places the picture") +TouchControls.active = false +TouchControls.enabled = true +TouchControls.controllerHidden = true +check(TouchControls:visible(), "a gamepad does not hide a decorative bezel") + +-- A selected skin is also a presentation overlay on desktop. The touch +-- input gate remains separate from whether its artwork is drawn. +TouchSkin.setActive(skin) +TouchSkin.setOverlayLive(false) +check(not TouchSkin.decorativeOnly(), "a skin with binds is not decoration") +check(TouchSkin.drawable(), "and it still draws where touch input is off") +check(TouchSkin.hasViewport(), "so its screen placement stays available") +check(TouchControls:visible(), "and it draws over a desktop window") +TouchSkin.setOverlayLive(true) +check(TouchSkin.drawable(), "with the overlay live it draws again") +TouchControls.active = true +TouchControls.controllerHidden = false + +-- ------------------------------------------------------- native format + +local native = TouchSkin.serialize(skin) +check(native:find("^return {"), "serialize emits a Lua data chunk") +local back, nerr = TouchSkin.parseNative(native) +check(back ~= nil, "native round-trip parses: " .. tostring(nerr)) +if back then + eq(#back.pages, #skin.pages, "round-trip keeps the page count") + local bp, sp = back.pages[1], skin.pages[1] + eq(#bp.controls, #sp.controls, "round-trip keeps the control count") + eq(bp.controls[1].spec, sp.controls[1].spec, "round-trip keeps binds") + eq(bp.controls[1].rangeX, sp.controls[1].rangeX, "round-trip keeps half extents") + eq(bp.controls[1].shape, sp.controls[1].shape, "round-trip keeps the hitbox shape") + eq(bp.viewport.h, sp.viewport.h, "round-trip keeps the viewport") + check(bp.viewportFill, "round-trip keeps viewport_fill") + eq(bp.alphaMod, sp.alphaMod, "round-trip keeps alpha_mod") + eq(bp.controls[3].decorative, true, "round-trip keeps decoration") + eq(bp.controls[4].nextTarget, "second", "round-trip keeps next_target") +end + +-- the native format carries the separate pressed image a .cfg cannot +local twoState = assert(TouchSkin.parseNative([[ +return { pages = { { name = "p", controls = { + { bind = "a", x = 0.5, y = 0.5, w = 0.2, h = 0.2, shape = "radial", + image = "up.png", imagePressed = "down.png" }, +} } } } +]])) +eq(twoState.pages[1].controls[1].imagePath, "up.png", "native idle image") +eq(twoState.pages[1].controls[1].pressedImagePath, "down.png", "native pressed image") +eq(twoState.pages[1].controls[1].rangeX, 0.1, "native w is a full width, not a range") + +check(TouchSkin.parseNative("return 5") == nil, "a non-table skin.lua is rejected") +check(TouchSkin.parseNative("return { pages = {} }") == nil, "a pageless skin.lua is rejected") +check(TouchSkin.parseNative("this is not lua") == nil, "a broken skin.lua is rejected") +-- skins are third-party data: the chunk must not see the host globals +check(TouchSkin.parseNative("return { pages = { { controls = {} } }, hit = love ~= nil }") + ~= nil, "skin.lua loads in an empty environment") + +-- --------------------------------------------------------------- export + +local SkinZip = require("src.core.SkinZip") +local blob = SkinZip.encode({ + { name = "skin.lua", data = "return {}\n" }, + { name = "img/a.png", data = "\137PNG\r\n\26\n binary \0 bytes" }, +}) +eq(blob:sub(1, 4), "PK\3\4", "zip starts with a local file header") +check(blob:find("PK\5\6", 1, true) ~= nil, "zip ends with a central directory record") +check(blob:find("img/a.png", 1, true) ~= nil, "zip carries the entry name") +check(blob:find("binary", 1, true) ~= nil, "stored entries keep their bytes verbatim") + + +TouchSkin.setActive(nil) +TouchControls:setHotkeyHandler(nil) + +-- ------------------------------------------ auto-rotate (#1503) + +-- RetroArch overlays name a portrait page and a landscape page and wire +-- overlay_next between them. Play has to pick the one that matches the +-- window, or landscape stretches the portrait ranges into wide ovals. +local ORIENT_CFG = [[ +overlays = 2 +overlay0_name = "portrait" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_aspect_ratio = 0.45 +overlay0_descs = 1 +overlay0_desc0 = "a,0.84382,0.69563,radial,0.09722,0.04375" + +overlay1_name = "landscape" +overlay1_full_screen = true +overlay1_normalized = true +overlay1_aspect_ratio = 2.22222222222222 +overlay1_descs = 1 +overlay1_desc0 = "a,0.8307031248,0.8614400000,radial,0.0364168752,0.0813300000" +]] +local orient = assert(TouchSkin.parse(ORIENT_CFG)) +check(orient.pages[1].aspectFromCfg, "portrait page locks the cfg aspect_ratio") +check(orient.pages[2].aspectFromCfg, "landscape page locks the cfg aspect_ratio") +eq(orient.pages[1].orient, "portrait", "a portrait page name locks on import") +eq(orient.pages[2].orient, "landscape", "a landscape page name locks on import") +check(TouchSkin.hasOrientPair(orient), "the pair is an auto-rotate overlay") +TouchSkin.setActive(orient) +TouchSkin.autoOrient = true + +local PW, PH = 720, 1600 +TouchSkin.setSurface(0, 0, PW, PH) +eq(TouchSkin.page().name, "portrait", "a tall window picks the portrait page") +local _, _, pHalfW, pHalfH = + TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], PW, PH) +eq(math.floor(pHalfW + 0.5), 70, "portrait A half-width follows range_x") +eq(math.floor(pHalfH + 0.5), 70, "portrait A half-height follows range_y") + +local LW, LH = 1600, 720 +TouchSkin.setSurface(0, 0, LW, LH) +eq(TouchSkin.page().name, "landscape", "a wide window picks the landscape page") +local _, _, lHalfW, lHalfH = + TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], LW, LH) +eq(math.floor(lHalfW + 0.5), 58, "landscape A half-width follows the landscape range") +eq(math.floor(lHalfH + 0.5), 59, "landscape A half-height follows the landscape range") +check(math.abs(lHalfW - lHalfH) < 2, "landscape A stays round instead of stretching") + +-- 16:9 is not the overlay's 20:9. Letterbox to aspect_ratio so A stays round +-- instead of filling the window and turning into a tall oval (#1503). +TouchSkin.setSurface(0, 0, 1920, 1080) +eq(TouchSkin.page().name, "landscape", "16:9 still picks landscape") +local _, _, boxW, boxH = TouchSkin.pageBox(TouchSkin.page(), 1920, 1080) +eq(math.floor(boxW + 0.5), 1920, "letterbox keeps the 16:9 width") +eq(math.floor(boxH + 0.5), 864, "and the overlay's 20:9 height") +local _, _, sHalfW, sHalfH = + TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], 1920, 1080) +check(math.abs(sHalfW - sHalfH) < 2, "A stays round on a 16:9 window") + +TouchSkin.setSurface(0, 0, LW, LH) +local aCx, aCy, aHalfW, aHalfH = + TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], LW, LH) +local aW, aH = aHalfW * 2, aHalfH * 2 +local sx, sy = TouchSkin.imageFit(210, 210, aW, aH) +eq(sx, aW / 210, "desc art scales to the dest width") +eq(sy, aH / 210, "desc art scales to the dest height") +check(210 * sx <= aW + 1e-6 and 210 * sy <= aH + 1e-6, + "the whole bitmap lands inside its dest box") +check(sx >= math.min(aW / 210, aH / 210) - 1e-6, + "and it is not shrunk below a contain fit") +local lx, ly = TouchSkin.imageFit(327, 193, 218, 129) +check(327 * lx <= 218 + 1e-6 and 193 * ly <= 129 + 1e-6, + "a wide shoulder PNG is not cropped by its dest box") +check(TouchSkin.imageFit(0, 0, aW, aH) == nil, "a zero-sized image has no fit") +local wx, wy = TouchSkin.imageFit(100, 50, 200, 200) +eq(wx, 2, "independent X scale, not one uniform cover scale") +eq(wy, 4, "independent Y scale") +check(aCx > 0 and aCy > 0, "landscape A sits inside the page box") + +local nativeOrient = TouchSkin.parseNative(TouchSkin.serialize(orient)) +check(nativeOrient and nativeOrient.pages[2].aspectFromCfg, + "native export keeps the aspect lock") + +-- an explicit lock wins over the page name, so a studio-authored "main" +-- page can still auto-rotate in play +local LOCK_CFG = [[ +overlays = 2 +overlay0_name = "main" +overlay0_full_screen = true +overlay0_descs = 1 +overlay0_desc0 = "a,0.5,0.5,radial,0.05,0.05" +overlay1_name = "wide" +overlay1_full_screen = true +overlay1_descs = 1 +overlay1_desc0 = "a,0.5,0.5,radial,0.05,0.05" +]] +local locked = assert(TouchSkin.parse(LOCK_CFG)) +locked.pages[1].orient = "portrait" +locked.pages[2].orient = "landscape" +TouchSkin.setActive(locked) +TouchSkin.setSurface(0, 0, 1600, 720) +eq(TouchSkin.page().name, "wide", "orient lock auto-rotates a page not named landscape") +local roundTrip = TouchSkin.parseNative(TouchSkin.serialize(locked)) +eq(roundTrip.pages[1].orient, "portrait", "native export keeps a portrait lock") +eq(roundTrip.pages[2].orient, "landscape", "and a landscape lock") + +TouchSkin.setActive(orient) +TouchSkin.autoOrient = false +TouchSkin.pageIndex = 1 +eq(TouchSkin.page().name, "portrait", + "the studio can keep a portrait page on a landscape canvas") +TouchSkin.autoOrient = true +TouchSkin.setSurface(nil) + +-- pages that are not a portrait/landscape pair (gb_anim) stay put +TouchSkin.setActive(skin) +TouchSkin.setSurface(0, 0, LW, LH) +eq(TouchSkin.page().name, "shell", "a non-oriented skin does not auto-rotate") +TouchSkin.setSurface(nil) +TouchSkin.setActive(nil) + +T.finish("touch_skin") From f885d62451396d9d994054885a9b6615c54dc7d5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:48:25 +0100 Subject: [PATCH 123/187] Expose installed skins through native options and test host ownership --- src/ui/OptionsMenu.lua | 21 ++++ tests/engine/touch_skin_native.lua | 152 +++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 tests/engine/touch_skin_native.lua diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d6d1f500d..3a4338215 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -522,6 +522,27 @@ local function buildRows(game) return true end }, } + -- The same installed library is usable as mobile controls or a desktop bezel. + local skins = require('src.core.TouchSkin').list() + if #skins > 0 then + rows[#rows + 1] = { id = 'touchSkin', label = Strings('TOUCH SKIN'), + value = function(g) + local tc = g.save.options.touchControls + return type(tc) == 'table' and tc.skin or Strings('DEFAULT') + end, + step = function(g, dir) + local tc = g.save.options.touchControls + tc = type(tc) == 'table' and tc or {} + local index = 0 + for i, skin in ipairs(skins) do if skin.id == tc.skin then index = i; break end end + index = (index + (dir or 1)) % (#skins + 1) + tc.skin = index > 0 and skins[index].id or nil + if tc.skin then tc.enabled = true end + g.save.options.touchControls = tc + require('src.core.TouchControls'):applyOptions(g.save.options) + return true + end } + end -- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks if not GBCFX.isSupported() then local filtered = {} diff --git a/tests/engine/touch_skin_native.lua b/tests/engine/touch_skin_native.lua new file mode 100644 index 000000000..050cce8b2 --- /dev/null +++ b/tests/engine/touch_skin_native.lua @@ -0,0 +1,152 @@ +-- Native host integration, using owned memory-only skins. No ROM/profile IO. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local Skin = require('src.core.TouchSkin') +local Touch = require('src.core.TouchControls') +local Input = require('src.core.Input') +local V = require('src.render.GameViewport') +local Runtime = require('src.mods.Runtime') +local Save = require('src.core.SaveData') +local Game = require('src.core.Game') +local W, H = 640, 480 +love.graphics.getDimensions = function() return W, H end +love.graphics.getPixelDimensions = function() return W * 2, H * 2 end +local files, dirs = {}, {skins=true, ['assets/skins']=true} +local fs = {} +function fs.read(path) return files[path] end +function fs.write(path, bytes) files[path] = bytes; return true end +function fs.getInfo(path) + if dirs[path] then return {type='directory'} end + if files[path] then return {type='file', size=#files[path]} end +end +function fs.createDirectory(path) dirs[path]=true; return true end +function fs.remove(path) files[path], dirs[path]=nil,nil; return true end +function fs.getDirectoryItems(path) + local found, out = {}, {} + for _, table_ in ipairs{dirs,files} do + for key in pairs(table_) do + local tail = key:sub(1,#path+1)==path..'/' and key:sub(#path+2) + local name = tail and tail:match('^([^/]+)') + if name and not found[name] then found[name]=true; out[#out+1]=name end + end + end + table.sort(out); return out +end +love.filesystem = fs +-- Same persistence seam as portable/account saves, with an explicit memory fs. +Save.persistenceFs = function() return fs end +local skin = Skin.newSkin('native_owned') +skin.pages[1].aspectFromCfg = false +skin.pages[1].viewport = {x=.1,y=.1,w=.8,h=.5} +skin.pages[1].controls = { + Skin.newControl('a|key:f5', .8,.8,.2,.2,'rect'), + Skin.newControl('hold_fast_forward', .2,.8,.2,.2,'rect'), +} +skin.pages[1].imagePath = 'img/bezel.png' +skin.pages[1].controls[1].imagePath = 'img/a.png' +dirs[skin.root], dirs[skin.root..'/img'] = true,true +files[skin.root..'/img/bezel.png'] = 'owned-bezel-image' +files[skin.root..'/img/a.png'] = 'owned-control-image' +local drawn = {} +love.graphics.newImage = function(path) + return {getWidth=function() return W end, getHeight=function() return H end, + getDimensions=function() return W,H end, setFilter=function() end, path=path} +end +love.graphics.draw = function(image,x,y,...) drawn[#drawn+1]={image=image,x=x,y=y} end +T.eq(Skin.saveTo(skin), 'skins/native_owned', 'native skin saves through persistence owner') +local restored = assert(Skin.load('skins/native_owned','native_owned')) +T.eq(#restored.pages[1].controls, 2, 'saved skin reloads both controls') +T.eq(restored.pages[1].controls[1].spec, 'a|key:f5', 'combined binding survives disk shape') +T.eq(#Skin.list(), 1, 'library discovers the saved native skin') +local export, missing = Skin.export(restored) +T.eq(#missing, 0, 'export finds every owned asset') +T.check(files[export]:find('owned-bezel-image',1,true) ~= nil, 'export contains original bezel bytes') +T.check(files[export]:find('owned-control-image',1,true) ~= nil, 'export contains original control bytes') +local ra = assert(Skin.exportRetroArch(restored)) +T.check(files[ra]:find('overlay0_desc0',1,true) ~= nil, 'RetroArch export contains actual bindings') +local delta = assert(Skin.exportDelta(restored, {system='gbc'})) +T.check(files[delta]:find('info.json',1,true) ~= nil, 'Delta export contains metadata') + +Touch:init(); Input:init() +Touch.active = true +Touch:applyOptions({touchControls={skin='native_owned'}}) +T.eq(Touch.skinId, 'native_owned', 'options select the real saved skin') +T.eq(Touch:config().skin, 'native_owned', 'editor config retains selection') +local keyEdges, speedEdges = {}, {} +love.keypressed = function(key) keyEdges[#keyEdges+1]='+'..key end +love.keyreleased = function(key) keyEdges[#keyEdges+1]='-'..key end +Touch:setHotkeyHandler(function(key, down) speedEdges[#speedEdges+1]=key..':'..tostring(down) end) +local cx,cy=Skin.controlGeometry(Skin.page(),Skin.page().controls[1],W,H) +T.check(Touch:touchpressed('one',cx,cy), 'custom A contact captured') +T.check(Touch:touchpressed('two',cx,cy), 'overlapping second contact captured') +T.check(Input:isDown('a'), 'custom A reaches real native input') +T.eq(#keyEdges,1, 'shared keyboard bind has one press edge') +Touch:touchreleased('one',cx,cy) +T.check(Input:isDown('a'), 'one release retains other A owner') +T.eq(#keyEdges,1, 'one release retains other key owner') +Touch:touchreleased('two',cx,cy) +T.check(not Input:isDown('a'), 'last release drops real native A') +T.eq(keyEdges[2],'-f5', 'last release drops native key') +local sx,sy=Skin.controlGeometry(Skin.page(),Skin.page().controls[2],W,H) +Touch:touchpressed('fast',sx,sy) +T.eq(speedEdges[1],'fast_forward_hold:true','skin speed action reaches host') +Touch:touchpressed('again',cx,cy) +Touch:reset() +T.eq(speedEdges[2],'fast_forward_hold:false','focus reset releases held speed action') +T.eq(keyEdges[#keyEdges],'-f5','focus reset releases keyboard binding') +T.check(not Input:isDown('a'), 'focus reset releases real native input') +Touch:touchpressed('reused',cx,cy) +Touch:touchpressed('reused',sx,sy) +T.check(not Input:isDown('a'), 'reused native pointer id releases previous owner') +Touch:reset() + +-- Decorative desktop skins draw after composition without stealing input. +Touch.active=false; Touch.controllerHidden=true +T.check(Touch:visible(), 'desktop/controller retains bezel artwork') +T.eq(Touch:touchpressed('desktop',cx,cy),nil,'desktop bezel does not capture touch input') +Touch:draw() +T.eq(#drawn,2,'draws bezel and control once each in window space') +Touch:applyOptions({touchControls={enabled=false,skin='native_owned'}}) +T.eq(Skin.active,nil,'disabling controls also clears skin viewport') +T.eq(Touch:visible(),false,'disabled skin is invisible') +Touch:applyOptions({touchControls={skin='missing'}}) +T.eq(Skin.active,nil,'missing skin cannot leave stale art selected') +T.check(Touch.skinError ~= nil,'missing skin reports its load error') + +-- Skin cutouts share the actual viewport owner; mod hooks keep final say. +Runtime.hooks,Runtime.events={},{} +Touch:applyOptions({touchControls={skin='native_owned'}}) +local x,y,w,h=Skin.viewport(W,H) +local r=V.begin(2) +T.eq(r.x,math.floor(x),'skin reserves native viewport x') +T.eq(r.y,math.floor(y),'skin reserves native viewport y') +T.eq(r.width,math.floor(w),'skin reserves native viewport width') +T.eq(r.height,math.floor(h),'skin reserves native viewport height') +local lx,ly,inside=V.toLocal(r.x+10,r.y+20) +T.eq(lx,10,'pointer maps into real cutout x') +T.eq(ly,20,'pointer maps into real cutout y') +T.check(inside,'pointer inside cutout is accepted') +V.finish({}); V.reset() +V.begin(4,false) +T.eq(V.active(),false,'existing native dual-screen layout keeps its owner') +V.finish({}); V.reset() +Skin.setActive(nil) +V.begin(2) +T.eq(V.active(),false,'no selected skin allocates no extra viewport') +V.finish({}); V.reset() + +-- Real engine hotkeys must release an explicit speed override and obey locks. +local host=setmetatable({speedOverride=2,save={options={speed=1}},writes=0}, {__index=Game}) +host.speedLocked=function() return false end +host.writeOptions=function(self) self.writes=self.writes+1 end +host:touchSkinHotkey('fast_forward_hold',true) +T.eq(host.speedOverride,4,'native held fast-forward engages') +host:touchSkinHotkey('fast_forward_hold',false) +T.eq(host.speedOverride,2,'native held fast-forward restores explicit override') +host:touchSkinHotkey('fast_forward_toggle',true) +T.eq(host.save.options.speed,2,'native toggle cycles persisted speed') +T.eq(host.writes,1,'native toggle persists through engine owner') +host.speedLocked=function() return true end +host:touchSkinHotkey('fast_forward_toggle',true) +T.eq(host.writes,1,'locked state blocks speed preference changes') +T.finish('native custom-skin ownership, persistence, rendering and input') From ab5b84edf47b505f026f32967479a19b6acaa529 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:49:34 +0100 Subject: [PATCH 124/187] Preserve native pointer ownership and isolate archive test fixtures --- src/core/Game.lua | 3 ++- src/core/TouchSkin.lua | 2 +- tests/engine/skin_format_import_test.lua | 11 +++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/core/Game.lua b/src/core/Game.lua index 592914777..50f7370b7 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1809,7 +1809,8 @@ function Game:touchpressed(id, x, y, dx, dy, pressure) -- one opens a different screen before the finger is lifted. if not self.secondScreenInjecting and TouchControls.visible and TouchControls:visible() and TouchControls:hitTest(x,y) then - if TouchControls:touchpressed(id,x,y) then return end + if TouchControls:touchpressed(id,x,y) + or (TouchControls.touches and TouchControls.touches[id]) then return end end if modPointer(self, "pressed", id, x, y, dx, dy, pressure) then return end if offerPointer(self, "touchpressed", id, x, y) then return end diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index c0ff3f545..4ade71374 100644 --- a/src/core/TouchSkin.lua +++ b/src/core/TouchSkin.lua @@ -8,7 +8,7 @@ TouchSkin.USER_ROOT = "skins" TouchSkin.EXPORT_ROOT = "skins/_export" TouchSkin.GB_BUTTONS = { - a = "a", b = "b", x = "x", y = "y", start = "start", select = "select", + a = "a", b = "b", start = "start", select = "select", up = "up", down = "down", left = "left", right = "right", l = "l", r = "r", l1 = "l", r1 = "r", diff --git a/tests/engine/skin_format_import_test.lua b/tests/engine/skin_format_import_test.lua index 2b5452d24..7ab9394a7 100644 --- a/tests/engine/skin_format_import_test.lua +++ b/tests/engine/skin_format_import_test.lua @@ -2,6 +2,17 @@ -- GPLv3 + Section 7; see licenses/GEN1RECOMP-TOUCH-SKINS.md. package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end +-- The native general stub does not model mounts/FileData. These contracts use +-- a dedicated memory archive fixture, rather than pretending to open ZIPs. +love.filesystem = require('tests.skin_fs_fixture')() +local newImage = love.graphics.newImage +love.graphics.newImage = function(source) + if type(source)=='table' and source.contents then + return {getWidth=function() return 1 end,getHeight=function() return 1 end, + getDimensions=function() return 1,1 end,setFilter=function() end} + end + return newImage(source) +end local T = require("tests.harness") local check, eq = T.check, T.eq From 876385e90d35066ecf218f48f4642cfe983bca18 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:50:39 +0100 Subject: [PATCH 125/187] Track skin archive fixture and exercise normal options persistence --- .gitignore | 3 +++ tests/engine/touch_skin_native.lua | 26 ++++++++++++++++++ tests/skin_fs_fixture.lua | 42 ++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 tests/skin_fs_fixture.lua diff --git a/.gitignore b/.gitignore index fe97850f7..7e79b49e3 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,6 @@ mobile/ios/bundle_id.local # secrets as public -- but GitHub's secret scanning flags it, so it is kept out # of the repository and injected into release builds instead. /cloud_clients.lua + +# Owned memory-only custom-skin fixture. +!/tests/skin_fs_fixture.lua diff --git a/tests/engine/touch_skin_native.lua b/tests/engine/touch_skin_native.lua index 050cce8b2..cfddffd29 100644 --- a/tests/engine/touch_skin_native.lua +++ b/tests/engine/touch_skin_native.lua @@ -67,6 +67,32 @@ T.check(files[ra]:find('overlay0_desc0',1,true) ~= nil, 'RetroArch export contai local delta = assert(Skin.exportDelta(restored, {system='gbc'})) T.check(files[delta]:find('info.json',1,true) ~= nil, 'Delta export contains metadata') +-- The actual option row accepts normal input and retains other layout options. +local Version=require('src.core.GameVersion') +Version.set('crystal') +local Menu=require('src.ui.OptionsMenu') +local Stack=require('src.core.StateStack') +local g={data=T.fixtures.fresh(),save=Save.newGame(),writes=0,input={pressed={}}} +g.stack=setmetatable({},{__index=Stack});g.stack:init() +g.input.wasPressed=function(self,key) return self.pressed[key]==true end +g.writeOptions=function(self) self.writes=self.writes+1;self.persisted=Save.encode(self.save) end +g.save.options.touchControls={layouts={portrait={scale=1.2}}} +local menu=Menu.new(g);g.stack:push(menu) +local page=menu:focusRow('touchSkin') +T.check(page ~= nil,'installed skin exposes a native Options row') +if page then + g.input.pressed={right=true};page:update(1/60);g.input.pressed={} + T.eq(g.save.options.touchControls.skin,'native_owned','normal RIGHT selects installed skin') + T.eq(Skin.active and Skin.active.id,'native_owned','normal Options input applies real skin') + T.eq(g.writes,1,'normal Options input writes through native owner') + local roundTrip=assert(Save.decode(g.persisted)) + T.eq(roundTrip.options.touchControls.skin,'native_owned','save codec round-trips selected skin') + T.eq(roundTrip.options.touchControls.layouts.portrait.scale,1.2,'skin choice retains layout preferences') + g.input.pressed={right=true};page:update(1/60);g.input.pressed={} + T.eq(g.save.options.touchControls.skin,nil,'normal RIGHT wraps to default pad') + T.eq(Skin.active,nil,'default choice releases custom viewport') +end + Touch:init(); Input:init() Touch.active = true Touch:applyOptions({touchControls={skin='native_owned'}}) diff --git a/tests/skin_fs_fixture.lua b/tests/skin_fs_fixture.lua new file mode 100644 index 000000000..dec5f8060 --- /dev/null +++ b/tests/skin_fs_fixture.lua @@ -0,0 +1,42 @@ +-- Memory PhysFS fixture for archive-member tests; no real ZIP/GPU acceptance. +local function fixture() + local fs, files, mounts = {}, {}, {} + local function resolve(path) + if files[path] then return path end + for i=#mounts,1,-1 do + local m=mounts[i] + if path==m.point then return m.archive end + if path:sub(1,#m.point+1)==m.point..'/' then + return m.archive..'/'..path:sub(#m.point+2) + end + end + return path + end + function fs.read(path) return files[resolve(path)] end + function fs.write(path,data) files[path]=data; return true end + function fs.remove(path) files[path]=nil; return true end + function fs.getInfo(path) + path=resolve(path) + if files[path] then return {type='file'} end + for key in pairs(files) do + if key:sub(1,#path+1)==path..'/' then return {type='directory'} end + end + end + function fs.getDirectoryItems(path) + path=resolve(path) + local out,seen={},{} + for key in pairs(files) do + if key:sub(1,#path+1)==path..'/' then + local name=key:sub(#path+2):match('^[^/]+') + if name and not seen[name] then seen[name]=true;out[#out+1]=name end + end + end + table.sort(out);return out + end + function fs.mount(archive,point) mounts[#mounts+1]={archive=archive,point=point};return true end + function fs.createDirectory() return true end + function fs.newFileData(bytes,name) return {contents=bytes,name=name} end + function fs.getSaveDirectory() return '/memory-only-skin-fixture' end + return fs +end +return fixture From 90296f90323a4c2f5d19d759f5fbb98abbef3921 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:52:59 +0100 Subject: [PATCH 126/187] Verify custom skin API through actual mod loader and hook chain --- tests/engine/touch_skin_native.lua | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/engine/touch_skin_native.lua b/tests/engine/touch_skin_native.lua index cfddffd29..e2dffb84f 100644 --- a/tests/engine/touch_skin_native.lua +++ b/tests/engine/touch_skin_native.lua @@ -8,6 +8,20 @@ local V = require('src.render.GameViewport') local Runtime = require('src.mods.Runtime') local Save = require('src.core.SaveData') local Game = require('src.core.Game') +local sdk=T.sdk.loadMods({'mods/skin_consumer'},{data=T.fixtures.fresh(),fs=T.sdk.memfs({ + ['mods/skin_consumer/manifest.json']=require('src.link.Json').encode({ + id='skin_consumer',name='Unmodified skin API consumer fixture',version='1.0.0',api=2,entry='main.lua'}), + ['mods/skin_consumer/main.lua']=[[ + local mod=... + local S=require('src.core.TouchSkin') + mod.exports.hasGeometry=type(S.page)=='function' and type(S.controlGeometry)=='function' + mod.exports.hasLibrary=type(S.installArchive)=='function' and type(S.exportDelta)=='function' + ]], +})}) +T.eq(#sdk.errors,0,'actual mod loader resolves native skin API') +T.eq(sdk.loader.apis.skin_consumer.exports.hasGeometry,true,'optional mod geometry consumer receives real API') +T.eq(sdk.loader.apis.skin_consumer.exports.hasLibrary,true,'optional mod library consumer receives real API') +sdk.release() local W, H = 640, 480 love.graphics.getDimensions = function() return W, H end love.graphics.getPixelDimensions = function() return W * 2, H * 2 end @@ -140,7 +154,9 @@ T.eq(Skin.active,nil,'missing skin cannot leave stale art selected') T.check(Touch.skinError ~= nil,'missing skin reports its load error') -- Skin cutouts share the actual viewport owner; mod hooks keep final say. -Runtime.hooks,Runtime.events={},{} +local Hooks=require('src.mods.Hooks') +local hooks=Hooks.new() +Runtime.install(require('src.mods.Events').new(),hooks,{}) Touch:applyOptions({touchControls={skin='native_owned'}}) local x,y,w,h=Skin.viewport(W,H) local r=V.begin(2) @@ -153,6 +169,15 @@ T.eq(lx,10,'pointer maps into real cutout x') T.eq(ly,20,'pointer maps into real cutout y') T.check(inside,'pointer inside cutout is accepted') V.finish({}); V.reset() +local undo=hooks:wrap('render.viewport',function(next,ctx) + local base=next(ctx) + T.eq(base.width,math.floor(w),'mod receives selected skin as default viewport') + return {x=20,y=30,width=100,height=90} +end,0,'owned-fixture') +r=V.begin(2) +T.eq(r.x,20,'layout mod can override selected skin viewport') +T.eq(r.width,100,'layout mod retains final cutout authority') +V.finish({}); V.reset(); undo() V.begin(4,false) T.eq(V.active(),false,'existing native dual-screen layout keeps its owner') V.finish({}); V.reset() From f2755266f256be70285ec9dd0152e1ec637ba0ff Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:54:11 +0100 Subject: [PATCH 127/187] Document native skin runtime provenance and remaining acceptance scope --- docs/native-touch-skins.md | 72 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/native-touch-skins.md diff --git a/docs/native-touch-skins.md b/docs/native-touch-skins.md new file mode 100644 index 000000000..f79fa8b79 --- /dev/null +++ b/docs/native-touch-skins.md @@ -0,0 +1,72 @@ +# Native custom touch skins + +The shared engine now supplies the real `src.core.TouchSkin` service used by +Gen1 mods. This is engine work; mod files and their optional geometry consumers +do not need changes. The implementation is adapted from Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, with its original copyright and +[licence](../licenses/GEN1RECOMP-TOUCH-SKINS.md) retained. No proprietary Gen1 +launcher code or bundled third-party skin artwork is included. + +## Installed skins and options + +Skins live under `skins//skin.lua`, `skins//overlay.cfg`, or in an +installed `skins/.zip` / `.deltaskin` inside the engine's writable storage. +`TouchSkin.installArchive(name, bytes)` installs archive bytes through the same +persistence filesystem used by native options. `TouchSkin.list()` discovers +native, RetroArch and Delta configurations; `load` loads their artwork and +controls. A wrapped Delta archive may contain its metadata in a subdirectory. + +When installed skins exist, native OPTIONS offers **TOUCH SKIN**. LEFT/RIGHT +cycles the library and DEFAULT, applies the choice immediately and saves +`options.touchControls.skin`. DEFAULT restores the existing native pad. The +position/size editor retains this selection in its save snapshot. Turning the +pad off clears the active skin and its cutout. Invalid/missing selections +restore the native presentation and expose `TouchControls.skinError`. + +The custom-skin studio/library editor and its launcher integration remain a +separate, open Gen1 parity requirement. This runtime PR does not claim that +the existing native position editor implements that studio. + +## Input and presentation owners + +* Controls support GB/GBA buttons, combinations, directional areas, sectors, + arbitrary key bindings, held speed actions and overlay page switching. + Multiple contacts share per-button and per-key ownership. Release, a reused + pointer id, cancellation polling and focus reset release the correct holds. +* The real `Game.touchSkinHotkey` routes held/toggled speed, reset and OPTIONS + through native engine owners. Existing fixed-speed state locks still apply. +* Native `GameViewport` fits the game into the active skin's screen cutout and + maps gameplay coordinates into it. Explicit `render.viewport` mods retain + final layout authority; the skin is their default rectangle. Unsupported + native combined-screen layouts retain their existing owner. +* Bezel/control artwork draws in OS-window space after final composition. + A desktop bezel survives controller use without capturing gameplay input. + With no selected skin, the viewport stays on its existing direct path. +* The native default pad, Gen4 X/Y layout, sticks, free-camera drag and touch + cancellation recovery are retained; the Gen1 controller file is not used + as a replacement for those implementations. + +The library retains native/RetroArch/Delta export, native editing data helpers, +image import and viewport detection. Raster JPEG data embedded in a PDF may +be recovered; vector-only PDF artwork reports that conversion is required. + +## Verification scope + +ROM-free suites: + +```sh +luajit tests/engine/touch_skin_test.lua +luajit tests/engine/touch_skin_dpad_area.lua +luajit tests/engine/skin_format_import_test.lua +luajit tests/engine/touch_skin_native.lua +luajit tests/engine/touch_orientation_bug633.lua +luajit tests/engine/game_viewport.lua +``` + +The first three preserve Gen1 format/input contracts, with third-party bundled +art assertions replaced by owned memory-only assets in the native suite. The +native suite also exercises the actual mod loader, normal OPTIONS input, +save-codec round trips, contact ownership, rendering calls and viewport hooks. +Archive-member fixtures model PhysFS mounts explicitly; they are not evidence +of real ZIP decoding or GPU/window acceptance. Real runtime import, both GPU +backends, rotation and packaged desktop/mobile builds still require acceptance. From 9e51cd042c5b2599c4f4c16c11adde9b54fdcd33 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:58:33 +0100 Subject: [PATCH 128/187] Adapt skin integration to offered upstream viewport and flat options owners --- LICENSE-ROXAS2712.md | 88 ++++++++++++++++++++++++++++++ NOTICE.md | 4 ++ docs/native-touch-skins.md | 4 ++ src/core/TouchSkin.lua | 6 +- tests/engine/touch_skin_native.lua | 6 +- 5 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 LICENSE-ROXAS2712.md diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..fbcffd47c --- /dev/null +++ b/LICENSE-ROXAS2712.md @@ -0,0 +1,88 @@ +# Roxas2712 — Fork Contributions + +Version 1.0 · Effective 25 October 2026 + +Copyright © 2026 Roxas2712, to the extent of rights held. +All rights reserved except as expressly granted below or already granted. + +## 1. Scope and ownership + +This notice applies only to original contributions made by Roxas2712 +(GitHub: Roxas2712) to `Roxas2712/Gen2Recomped`, including original additions +within modified files, insofar as he owns the relevant rights. Git history, +pull-request diffs and source provenance identify contributions; a commit +author or a whole-file change alone does not establish ownership of every +part of that file. + +It does not claim ownership of Cedric's work, Gen1Recomp / BOIS CLUB GAMES +work, other contributors' work, third-party dependencies, or ROM-derived +content. Their applicable licences and notices remain in force. It does not +grant permission to distribute the complete combined fork where another +rights holder's permission is required. + +## 2. General permission and reserved rights + +For contributions covered by this notice and not already licensed more +broadly, you may read and study the published source and make private, +non-commercial modifications and builds for your own use. This grants no +general permission to redistribute, sublicense, sell, or offer those +contributions as a hosted service. Other uses require Roxas2712's +written permission, except for the specific permission below and rights +available independently under applicable law. + +Keep the relevant copyright, licence and attribution notices. Do not +represent Roxas2712's contributions as exclusively authored by someone +else. Public source availability alone does not put them in the public +domain or grant an open-source licence. + +## 3. Specific permission for Cedric / UNDERdecoded + +Roxas2712 expressly grants Cedric (Ced; GitHub: UNDERdecoded / +UNDERdecodedHD) a non-exclusive, worldwide, perpetual, royalty-free +permission to use, reproduce, integrate, modify and distribute the covered +contributions that Roxas2712 submits or expressly offers through pull +requests to `UNDERdecoded/Gen2Recomped`, for Cedric's Gen2Recomped / +RetroRecompiled project. This includes its renamed continuation maintained +by Cedric, its official builds, and its commercial operation. + +Cedric may license the incorporated contributions to recipients as part of +that project, subject to preserving this permission's applicable credit and +notice requirements. This permission is not a copyright assignment, is not +exclusive, and does not cover unpublished work, separate repositories, +ModCompass service operation, credentials, or content not offered in those +pull requests. Roxas2712 retains his own rights to his contributions. + +For uses relying on this permission: + +- retain the relevant source copyright and licence notices; +- give visible credit in the project's README or equivalent public project + page, and in the application's existing About / Credits view, if present; +- use at least the following credit, or a substantially equivalent one: + + > Contributions by Roxas2712 — + > https://github.com/Roxas2712 + +A maintained consolidated contributor credit is sufficient; a separate +credit on every screen or for every change is not required. Adapted +contributions must not be attributed exclusively to another author. Other +contributors' credits must also remain intact. + +## 4. Earlier licences and permissions remain effective + +This notice does not revoke, narrow, or retrospectively add conditions to +any permission already validly granted, including MIT / GPL grants, +separate agreements, or permissions arising from previously submitted +pull requests. Where an earlier grant permits a use independently of this +notice, its existing terms govern that use. For such earlier uses, the +visible credit above is requested to the extent it is not already required. + +For subsequent contributions, this is the default statement of Roxas2712's own reserved rights and the specific PR permission, subject to any +applicable existing licence obligations or separately agreed terms. It +does not change another rights holder's licence or guarantee compatibility +of a particular combination of code. + +## 5. No warranty + +To the extent permitted by applicable law, these contributions are provided +as is, without warranty. No support obligation, endorsement, or rights to +third-party code, game assets, names, or trademarks are granted. diff --git a/NOTICE.md b/NOTICE.md index 67a3a6b5f..0cbd50f7a 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -12,3 +12,7 @@ https://github.com/Roxas2712 Existing upstream and Gen1 rights and licence grants remain effective; this notice assigns no other author's work to Roxas2712. No proprietary Gen1 launcher code or third-party bundled skin art is included. + +Roxas2712's independently owned additions are offered under +[LICENSE-ROXAS2712.md](LICENSE-ROXAS2712.md), subject to prior grants and +the preserved Gen1 GPL terms. Existing GPL-covered code is not relicensed. diff --git a/docs/native-touch-skins.md b/docs/native-touch-skins.md index f79fa8b79..109b07f42 100644 --- a/docs/native-touch-skins.md +++ b/docs/native-touch-skins.md @@ -1,5 +1,9 @@ # Native custom touch skins +Upstream stack: this change is based on the offered viewport/window contract +PR #124 (and its prerequisites). Merge those first. The runtime needs their +actual game rectangle and input mapping; no viewport stub is provided here. + The shared engine now supplies the real `src.core.TouchSkin` service used by Gen1 mods. This is engine work; mod files and their optional geometry consumers do not need changes. The implementation is adapted from Gen1Recomp diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index 4ade71374..9c224184c 100644 --- a/src/core/TouchSkin.lua +++ b/src/core/TouchSkin.lua @@ -354,7 +354,11 @@ local function readFile(path) local ok, data = pcall(love.filesystem.read, path) if ok and data then return data end end - return require("src.core.FilePicker").read(path) + local ok, handle = pcall(io.open, path, "rb") + if not ok or not handle then return nil end + local read, bytes = pcall(handle.read, handle, "*a") + pcall(handle.close, handle) + return read and bytes or nil end local function listDir(path) diff --git a/tests/engine/touch_skin_native.lua b/tests/engine/touch_skin_native.lua index e2dffb84f..73488bde8 100644 --- a/tests/engine/touch_skin_native.lua +++ b/tests/engine/touch_skin_native.lua @@ -92,7 +92,11 @@ g.input.wasPressed=function(self,key) return self.pressed[key]==true end g.writeOptions=function(self) self.writes=self.writes+1;self.persisted=Save.encode(self.save) end g.save.options.touchControls={layouts={portrait={scale=1.2}}} local menu=Menu.new(g);g.stack:push(menu) -local page=menu:focusRow('touchSkin') +-- This offered base retains upstream's actual flat OPTIONS page. +local page +for index,row in ipairs(menu.rows) do + if row.id=='touchSkin' then menu.index=index;page=menu;break end +end T.check(page ~= nil,'installed skin exposes a native Options row') if page then g.input.pressed={right=true};page:update(1/60);g.input.pressed={} From f32c163590bb27ed97c8630a232070d4ba9c9856 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:00:52 +0100 Subject: [PATCH 129/187] Ship preserved Gen1 touch skin licence in shared payload packer --- scripts/pack_love.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/pack_love.sh b/scripts/pack_love.sh index 7d4ce2ede..0ada2fe48 100755 --- a/scripts/pack_love.sh +++ b/scripts/pack_love.sh @@ -155,6 +155,14 @@ for licence in LICENSE LICENSE.MD LICENSE.md; do [ -f "$ROOT/$licence" ] && cp "$ROOT/$licence" "$STAGE/" && break done +# Preserve the original licence alongside the actual derived skin runtime. +if [ -f "$ROOT/src/core/TouchSkin.lua" ]; then + skin_licence="licenses/GEN1RECOMP-TOUCH-SKINS.md" + [ -f "$ROOT/$skin_licence" ] || fail "missing Gen1 touch-skin licence" + mkdir -p "$STAGE/licenses" + cp "$ROOT/$skin_licence" "$STAGE/$skin_licence" +fi + # ---------------------------------------------------------------- excludes # Pruned from the STAGE rather than passed to zip as -x patterns: the two # forms disagree on how they anchor a leading directory, and a silent From c0b6eb23d8c7eafcc0a3d7ac4f28f834992cc51d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:01:49 +0100 Subject: [PATCH 130/187] Retain contribution and provenance notices in upstream payloads --- scripts/pack_love.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/pack_love.sh b/scripts/pack_love.sh index 0ada2fe48..e9bac5a32 100755 --- a/scripts/pack_love.sh +++ b/scripts/pack_love.sh @@ -155,6 +155,10 @@ for licence in LICENSE LICENSE.MD LICENSE.md; do [ -f "$ROOT/$licence" ] && cp "$ROOT/$licence" "$STAGE/" && break done +for notice in NOTICE.md LICENSE-ROXAS2712.md; do + if [ -f "$ROOT/$notice" ]; then cp "$ROOT/$notice" "$STAGE/"; fi +done + # Preserve the original licence alongside the actual derived skin runtime. if [ -f "$ROOT/src/core/TouchSkin.lua" ]; then skin_licence="licenses/GEN1RECOMP-TOUCH-SKINS.md" From dc29c16a81ea4733edfe473dc74ca12b23fc4792 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:13:58 +0100 Subject: [PATCH 131/187] Retain mounted skin archives across real PhysFS library refreshes --- src/core/TouchSkin.lua | 30 ++++++++++++++++++++++++ tests/engine/skin_format_import_test.lua | 13 ++++++++++ tests/skin_fs_fixture.lua | 10 +++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index 9c224184c..cbaa824d4 100644 --- a/src/core/TouchSkin.lua +++ b/src/core/TouchSkin.lua @@ -706,9 +706,32 @@ function TouchSkin.load(root, id) return skin end +local mountedArchives = {} +local function unmountZip(archive) + local point = mountedArchives[archive] + if not point then return true end + if not (love and love.filesystem and love.filesystem.unmount) then + return nil, "cannot release mounted skin archive" + end + local ok, removed = pcall(love.filesystem.unmount, archive) + if not ok or removed ~= true then return nil, "skin archive is still in use" end + mountedArchives[archive] = nil + if TouchSkin.active and TouchSkin.active.root == point then TouchSkin.setActive(nil) end + for key in pairs(imageCache) do + if key:sub(1, #point + 1) == point .. "/" or key:sub(1, 6) == "bytes:" then + imageCache[key] = nil + end + end + return true +end + local function mountZip(archive, point) if not (love and love.filesystem and love.filesystem.mount) then return false end + -- PhysFS rejects a second mount of an already-mounted archive. A library + -- refresh must keep its existing entry instead of making it disappear. + if mountedArchives[archive] == point and findConfig(point) then return true end local ok, mounted = pcall(love.filesystem.mount, archive, point) + if ok and mounted == true then mountedArchives[archive] = point end return ok and mounted == true end @@ -774,6 +797,8 @@ function TouchSkin.installArchive(name, data) pcall(wfs().createDirectory, TouchSkin.USER_ROOT) local dest = TouchSkin.USER_ROOT .. "/" .. name + local released, releaseError = unmountZip(dest) + if not released then return nil, releaseError end local ok, err = wfs().write(dest, data) if not ok then return nil, tostring(err) end @@ -784,6 +809,7 @@ function TouchSkin.installArchive(name, data) end local skin = TouchSkin.load(entry.root, entry.id) if skin and require("src.core.DeltaSkin").needsConversion(skin) then + unmountZip(dest) wfs().remove(dest) return nil, TouchSkin.PDF_ONLY_MESSAGE end @@ -807,6 +833,10 @@ function TouchSkin.remove(id) if not (love and love.filesystem and love.filesystem.remove) then return nil, "no writable filesystem" end + if entry.archive then + local released, err = unmountZip(entry.archive) + if not released then return nil, err end + end local function removeTree(path) if isDir(path) and love.filesystem.getDirectoryItems then for _, name in ipairs(love.filesystem.getDirectoryItems(path)) do diff --git a/tests/engine/skin_format_import_test.lua b/tests/engine/skin_format_import_test.lua index 7ab9394a7..4dd93ea15 100644 --- a/tests/engine/skin_format_import_test.lua +++ b/tests/engine/skin_format_import_test.lua @@ -496,12 +496,17 @@ love.filesystem.write("skins/wrapped.deltaskin/MySkin/portrait.png", "\137PNG\r\ local wrappedId, wrappedErr = TouchSkin.installArchive("wrapped.deltaskin", "PK\3\4stub") eq(wrappedId, "wrapped", "a .deltaskin installs under its bare name: " .. tostring(wrappedErr)) +eq(TouchSkin.find('wrapped').id,'wrapped','refresh retains an already-mounted archive') +eq(TouchSkin.find('wrapped').id,'wrapped','repeated refresh still retains the archive') local wrapped = assert(TouchSkin.load("skins/_mounted/wrapped", "wrapped")) eq(wrapped.format, "delta", "the mounted archive is recognised as a Delta skin") eq(wrapped.name, "Wrapped", "and its name comes from info.json") eq(wrapped.pages[1].imagePath, "MySkin/portrait.png", "the wrapping folder is prefixed onto assets and the real file name wins") eq(#wrapped.pages[1].controls, 1, "the wrapped items parsed") +eq(TouchSkin.installArchive('wrapped.deltaskin','PK\3\4replacement'),'wrapped', + 'replacement releases the previous mount before installing') +check(TouchSkin.find('wrapped') ~= nil,'replacement is visible on a subsequent refresh') love.filesystem.write("skins/vector.deltaskin/info.json", PDF_JSON) local vectorId, vectorErr = TouchSkin.installArchive("vector.deltaskin", "PK\3\4stub") @@ -712,4 +717,12 @@ end eq(listedRoot, "skins/collide", "so the export cannot shadow the skin it came from") check(not listedExport, "and the export folder is not a skin of its own") +local oldUnmount=love.filesystem.unmount +love.filesystem.unmount=function() return false end +eq(TouchSkin.remove('wrapped'),nil,'a busy mounted archive is not deleted') +check(love.filesystem.read('skins/wrapped.deltaskin') ~= nil,'busy archive bytes are retained') +love.filesystem.unmount=oldUnmount +check(TouchSkin.remove('wrapped'),'removal releases its mount before deleting archive bytes') +eq(TouchSkin.find('wrapped'),nil,'removed archive is absent on subsequent refresh') + T.finish("skin_format_import") diff --git a/tests/skin_fs_fixture.lua b/tests/skin_fs_fixture.lua index dec5f8060..8d9394288 100644 --- a/tests/skin_fs_fixture.lua +++ b/tests/skin_fs_fixture.lua @@ -33,7 +33,15 @@ local function fixture() end table.sort(out);return out end - function fs.mount(archive,point) mounts[#mounts+1]={archive=archive,point=point};return true end + function fs.mount(archive,point) + if not files[archive] then return false end + for _,m in ipairs(mounts) do if m.archive==archive then return false end end + mounts[#mounts+1]={archive=archive,point=point};return true + end + function fs.unmount(archive) + for i=#mounts,1,-1 do if mounts[i].archive==archive then table.remove(mounts,i);return true end end + return false + end function fs.createDirectory() return true end function fs.newFileData(bytes,name) return {contents=bytes,name=name} end function fs.getSaveDirectory() return '/memory-only-skin-fixture' end From 0dab2c1374e38ae2e768873ca9cf52f6299e9d3c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:35:48 +0100 Subject: [PATCH 132/187] Restore native touch haptics preferences and ownership-edge feedback --- NOTICE.md | 3 ++ docs/native-touch-skins.md | 9 ++++ src/core/GenOptions.lua | 1 + src/core/SaveData.lua | 1 + src/core/TouchControls.lua | 58 ++++++++++++++++++++++- src/ui/OptionsMenu.lua | 14 ++++++ tests/engine/touch_haptics.lua | 87 ++++++++++++++++++++++++++++++++++ 7 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 tests/engine/touch_haptics.lua diff --git a/NOTICE.md b/NOTICE.md index 0cbd50f7a..476e8a9fa 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -16,3 +16,6 @@ launcher code or third-party bundled skin art is included. Roxas2712's independently owned additions are offered under [LICENSE-ROXAS2712.md](LICENSE-ROXAS2712.md), subject to prior grants and the preserved Gen1 GPL terms. Existing GPL-covered code is not relicensed. + +The identified haptic controls in TouchControls.lua are also adapted from the +same Gen1Recomp source under the same preserved GPL/Section7 grant. diff --git a/docs/native-touch-skins.md b/docs/native-touch-skins.md index 109b07f42..bb7002172 100644 --- a/docs/native-touch-skins.md +++ b/docs/native-touch-skins.md @@ -27,6 +27,14 @@ position/size editor retains this selection in its save snapshot. Turning the pad off clears the active skin and its cutout. Invalid/missing selections restore the native presentation and expose `TouchControls.skinError`. +Mobile OPTIONS also offers **HAPTICS**: OFF, LIGHT, NORMAL and STRONG, with +Gen1's light default and legacy-name normalization. The preference is shared +across cartridges, outside position/size layout snapshots. The first owner of +a button/key/action pulses through the real `love.system.vibrate`; additional +contacts holding that same input, release and focus reset do not retrigger it. +Runtimes without a vibrator retain normal input. Unit pulse checks do not +constitute physical phone vibration acceptance. + The custom-skin studio/library editor and its launcher integration remain a separate, open Gen1 parity requirement. This runtime PR does not claim that the existing native position editor implements that studio. @@ -63,6 +71,7 @@ luajit tests/engine/touch_skin_test.lua luajit tests/engine/touch_skin_dpad_area.lua luajit tests/engine/skin_format_import_test.lua luajit tests/engine/touch_skin_native.lua +luajit tests/engine/touch_haptics.lua luajit tests/engine/touch_orientation_bug633.lua luajit tests/engine/game_viewport.lua ``` diff --git a/src/core/GenOptions.lua b/src/core/GenOptions.lua index 001e56185..83d96a6d2 100644 --- a/src/core/GenOptions.lua +++ b/src/core/GenOptions.lua @@ -51,6 +51,7 @@ GenOptions.SHARED_ONLY = { modUpdateCache = true, saveSlots = true, touchControls = true, + haptics = true, tutorials = true, perGeneration = true, dataDir = true, diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2bfe53530..e134442c0 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -520,6 +520,7 @@ function SaveData.defaultOptions() -- layout (#633). Pre-#633 files stored one top-level positions table; -- TouchControls.normalizeConfig folds it into both orientations on load. touchControls = { enabled = true }, + haptics = "light", -- Where imported game data lives. false = LOVE's save directory (the -- appdata folder); a string is an absolute folder the player chose in -- LAUNCHER SETTINGS. See the game-data folder block above. diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 7f66ea929..c4c089316 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -114,6 +114,52 @@ local function clampScale(v) return v end +-- Haptic controls adapted from Gen1Recomp 535a5ec0. +-- Copyright (C) 2026 BOIS CLUB GAMES, LLC; licenses/GEN1RECOMP-TOUCH-SKINS.md. +TouchControls.HAPTICS = { "off", "light", "normal", "strong" } +TouchControls.HAPTIC_DEFAULT = "light" + +local HAPTIC_SECONDS = { + off = 0, light = 0.012, normal = 0.025, strong = 0.045, +} +local HAPTIC_LABELS = { + off = "OFF", light = "LIGHT", normal = "NORMAL", strong = "STRONG", +} + +function TouchControls.normalizeHaptics(level) + if level == "physical" then return "light" end + if level == "medium" then return "normal" end + if level == "heavy" then return "strong" end + if HAPTIC_SECONDS[level] then return level end + return TouchControls.HAPTIC_DEFAULT +end + +function TouchControls.hapticLabel(level) + return HAPTIC_LABELS[TouchControls.normalizeHaptics(level)] +end + +function TouchControls.cycleHaptics(level, dir) + local cur, idx = TouchControls.normalizeHaptics(level), 1 + for i, m in ipairs(TouchControls.HAPTICS) do + if m == cur then idx = i break end + end + local n = #TouchControls.HAPTICS + return TouchControls.HAPTICS[(idx - 1 + (dir or 1)) % n + 1] +end + +-- One pulse at the given level. Feature-guarded rather than platform-gated: +-- love.system.vibrate is a no-op on desktop and absent from the headless love +-- stubs, so the press path below stays identical everywhere and the tests +-- never reach a vibrator. +function TouchControls.buzz(level) + local secs = HAPTIC_SECONDS[TouchControls.normalizeHaptics(level)] + if not secs or secs <= 0 then return false end + if not (love and love.system and love.system.vibrate) then return false end + pcall(love.system.vibrate, secs) + return true +end + + -- Copy a persisted positions table, dropping unknown / non-numeric entries. -- Always a fresh table: two orientations seeded from the same pre-#633 -- layout must not alias, or dragging one would still move the other. @@ -233,6 +279,7 @@ function TouchControls:init() TouchSkin.setActive(nil) self.skinId, self.skinError = nil, nil self.hotkeysHeld, self.keysHeld = {}, {} + self.haptics = TouchControls.HAPTIC_DEFAULT self.enabled = true -- per-orientation buckets (#633); self.positions / self.scale mirror the -- one currently on screen so layout(), the editor and the tests keep a @@ -274,6 +321,7 @@ end function TouchControls:applyOptions(opts) local cfg = TouchControls.normalizeConfig(opts and opts.touchControls) self.enabled = cfg.enabled + self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics) TouchSkin.setOverlayLive(self.active) self:selectSkin(cfg.enabled and cfg.skin or nil) self.layouts = cfg.layouts @@ -530,7 +578,10 @@ end local function pressBtn(self, btn) local n = (self.held[btn] or 0) + 1 self.held[btn] = n - if n == 1 then Input:overlayPressed(btn) end + if n == 1 then + Input:overlayPressed(btn) + TouchControls.buzz(self.haptics) + end end local function releaseBtn(self, btn) @@ -585,6 +636,10 @@ local function fireHotkey(self, action, pressed, ctl) end local function enterControl(self, ctl) + local freshButton, freshAction = false, false + for _, btn in ipairs(ctl.buttons) do if not self.held[btn] then freshButton = true end end + for _, key in ipairs(ctl.keys) do if not (self.keysHeld and self.keysHeld[key]) then freshAction = true end end + for _, action in ipairs(ctl.hotkeys) do if not (self.hotkeysHeld and self.hotkeysHeld[action]) then freshAction = true end end for _, btn in ipairs(ctl.buttons) do pressBtn(self, btn) end @@ -593,6 +648,7 @@ local function enterControl(self, ctl) for _, action in ipairs(ctl.hotkeys) do if fireHotkey(self, action, true, ctl) then switched = true end end + if not freshButton and freshAction then TouchControls.buzz(self.haptics) end return switched end diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 3a4338215..d69ff5714 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -522,6 +522,20 @@ local function buildRows(game) return true end }, } + do + local osName = love.system and love.system.getOS and love.system.getOS() + if osName == 'Android' or osName == 'iOS' or os.getenv('POKEPORT_TOUCH') == '1' then + rows[#rows + 1] = { id = 'haptics', label = Strings('HAPTICS'), + value = function(g) return require('src.core.TouchControls').hapticLabel(g.save.options.haptics) end, + step = function(g, dir) + local Controls = require('src.core.TouchControls') + g.save.options.haptics = Controls.cycleHaptics(g.save.options.haptics, dir or 1) + Controls:applyOptions(g.save.options) + Controls.buzz(g.save.options.haptics) + return true + end } + end + end -- The same installed library is usable as mobile controls or a desktop bezel. local skins = require('src.core.TouchSkin').list() if #skins > 0 then diff --git a/tests/engine/touch_haptics.lua b/tests/engine/touch_haptics.lua new file mode 100644 index 000000000..c9a036823 --- /dev/null +++ b/tests/engine/touch_haptics.lua @@ -0,0 +1,87 @@ +-- Native haptic preferences and input edges. No physical vibrator claim. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Touch=require('src.core.TouchControls') +local Skin=require('src.core.TouchSkin') +local Input=require('src.core.Input') +local Save=require('src.core.SaveData') +local Version=require('src.core.GameVersion') +T.eq(Touch.normalizeHaptics(nil),'light') +T.eq(Touch.normalizeHaptics('physical'),'light') +T.eq(Touch.normalizeHaptics('medium'),'normal') +T.eq(Touch.normalizeHaptics('heavy'),'strong') +T.eq(Touch.normalizeHaptics('bad'),'light') +T.eq(Touch.cycleHaptics('strong',1),'off') +T.eq(Touch.cycleHaptics('off',-1),'strong') +for _,level in ipairs{'off','light','normal','strong'}do + T.eq(Touch.hapticLabel(level),level:upper(),'native haptic label') +end +local oldOS,oldVibrate=love.system.getOS,love.system.vibrate +local pulses={} +love.system.vibrate=function(seconds)pulses[#pulses+1]=seconds end +T.eq(Touch.buzz('off'),false,'OFF never pulses') +for _,row in ipairs{{'light',.012},{'normal',.025},{'strong',.045}}do + T.check(Touch.buzz(row[1]),'supported level requests a pulse') + T.eq(pulses[#pulses],row[2],'duration follows reference preset') +end +love.system.vibrate=nil +T.eq(Touch.buzz('light'),false,'runtime without vibrator remains usable') +love.system.vibrate=function(seconds)pulses[#pulses+1]=seconds end +Version.set('crystal');Input:init();Touch:init();Touch.active=true +Touch:applyOptions({haptics='normal'}) +local skin=Skin.newSkin('haptic-memory') +skin.pages[1].controls={Skin.newControl('a',.8,.8,.2,.2,'rect'), + Skin.newControl('key:f5',.2,.8,.2,.2,'rect')} +Skin.setActive(skin) +local w,h=love.graphics.getDimensions() +local ax,ay=Skin.controlGeometry(Skin.page(),Skin.page().controls[1],w,h) +local kx,ky=Skin.controlGeometry(Skin.page(),Skin.page().controls[2],w,h) +local n=#pulses +T.check(Touch:touchpressed('one',ax,ay)) +T.eq(#pulses,n+1,'first native button owner pulses') +Touch:touchpressed('two',ax,ay) +T.eq(#pulses,n+1,'second owner of held button does not pulse') +Touch:touchmoved('one',ax,ay) +T.eq(#pulses,n+1,'stationary held contact does not pulse') +Touch:touchreleased('one',ax,ay) +T.check(Input:isDown('a'),'first release retains other owner') +Touch:touchreleased('two',ax,ay) +T.eq(#pulses,n+1,'release does not pulse') +T.check(not Input:isDown('a')) +Touch:touchpressed('key-one',kx,ky) +T.eq(#pulses,n+2,'key-only control pulses on its first owner') +Touch:touchpressed('key-two',kx,ky) +T.eq(#pulses,n+2,'shared key owner does not retrigger vibration') +Touch:reset() +T.eq(#pulses,n+2,'focus reset releases holds without a new pulse') +Touch.haptics='off';Skin.setActive(skin) +Touch:touchpressed('off',ax,ay);Touch:touchreleased('off',ax,ay) +T.eq(#pulses,n+2,'OFF preference applies to actual skin input') +Skin.setActive(nil) +T.eq(Touch:config().haptics,nil,'layout snapshots do not relocate the global preference') +T.eq(Save.defaultOptions().haptics,'light','new native options retain the reference default') + +-- Normal OPTIONS input, not direct calls to the row implementation. +love.system.getOS=function()return 'Android'end +local Stack=require('src.core.StateStack') +local game={data=T.fixtures.fresh(),save=Save.newGame(),writes=0,input={pressed={}}} +game.stack=setmetatable({},{__index=Stack});game.stack:init() +game.input.wasPressed=function(self,key)return self.pressed[key]end +game.writeOptions=function(self)self.writes=self.writes+1;self.persisted=Save.encode(self.save)end +local menu=require('src.ui.OptionsMenu').new(game);game.stack:push(menu) +local page +for index,row in ipairs(menu.rows) do + if row.id=='haptics' then menu.index=index;page=menu;break end +end +T.check(page~=nil,'mobile native options offer haptic intensity') +if page then + game.input.pressed={right=true};page:update(1/60);game.input.pressed={} + T.eq(game.save.options.haptics,'normal','normal RIGHT changes haptic preference') + T.eq(Touch.haptics,'normal','native controller applies the chosen intensity') + T.eq(game.writes,1,'normal input persists exactly one change') + local saved=assert(Save.decode(game.persisted)) + T.eq(saved.options.haptics,'normal','native save codec retains haptics') +end +love.system.getOS, love.system.vibrate=oldOS,oldVibrate +Touch:reset();Skin.setActive(nil) +T.finish('native haptics preferences, ownership edges and normal options input') From e3faf94ddb62a34b35a6a15d47fe28157bcb94a9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:37:50 +0100 Subject: [PATCH 133/187] Initialize the upstream haptics fixture system interface --- tests/engine/touch_haptics.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/engine/touch_haptics.lua b/tests/engine/touch_haptics.lua index c9a036823..76fd659f4 100644 --- a/tests/engine/touch_haptics.lua +++ b/tests/engine/touch_haptics.lua @@ -16,6 +16,7 @@ T.eq(Touch.cycleHaptics('off',-1),'strong') for _,level in ipairs{'off','light','normal','strong'}do T.eq(Touch.hapticLabel(level),level:upper(),'native haptic label') end +love.system=love.system or {} local oldOS,oldVibrate=love.system.getOS,love.system.vibrate local pulses={} love.system.vibrate=function(seconds)pulses[#pulses+1]=seconds end From c25a8abd0255e337eed695b200db0e4c0729aef0 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:40:52 +0100 Subject: [PATCH 134/187] Complete the platform contract in the upstream haptics fixture --- tests/engine/touch_haptics.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/engine/touch_haptics.lua b/tests/engine/touch_haptics.lua index 76fd659f4..c63c7a296 100644 --- a/tests/engine/touch_haptics.lua +++ b/tests/engine/touch_haptics.lua @@ -18,6 +18,7 @@ for _,level in ipairs{'off','light','normal','strong'}do end love.system=love.system or {} local oldOS,oldVibrate=love.system.getOS,love.system.vibrate +love.system.getOS=oldOS or function() return "Linux" end local pulses={} love.system.vibrate=function(seconds)pulses[#pulses+1]=seconds end T.eq(Touch.buzz('off'),false,'OFF never pulses') From 6c63f3a0d6a032f5b6cf9a9a0ee20926b5e6f963 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:19:35 +0100 Subject: [PATCH 135/187] Supply real native speed lock owner for offered skin actions --- src/core/Game.lua | 16 +++++++++++++++- tests/engine/touch_skin_native.lua | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/core/Game.lua b/src/core/Game.lua index 50f7370b7..265ada8b2 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -416,6 +416,20 @@ end -- Options row takes effect immediately; speedOverride is the --speed / -- POKEPORT_SPEED run argument, which wins over the saved option so a bot -- or screenshot run does not depend on whatever the player last chose. +function Game:speedLocked() + if self.linkSession or (self.linkNet and not self.linkNet.closed) then + return true, 'link' + end + local states = self.stack and self.stack.states or {} + for i = #states, 1, -1 do + local state = states[i] + if state and (state.isFixedSpeed or state.isMinigame) then + return true, 'minigame' + end + end + return false +end + function Game:logicSpeed() local GameSpeed = require("src.core.GameSpeed") -- Link play is always 1X on both machines, and this wins over every other @@ -424,7 +438,7 @@ function Game:logicSpeed() -- than the opponent it is racing, and drove its own animation/message -- queue at a different rate than the peer it is locked to. Nothing about -- a match should depend on what either player set this to. - if self.linkSession or (self.linkNet and not self.linkNet.closed) then + if self:speedLocked() then return 1 end if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end diff --git a/tests/engine/touch_skin_native.lua b/tests/engine/touch_skin_native.lua index 73488bde8..fb6bda13e 100644 --- a/tests/engine/touch_skin_native.lua +++ b/tests/engine/touch_skin_native.lua @@ -204,4 +204,19 @@ T.eq(host.writes,1,'native toggle persists through engine owner') host.speedLocked=function() return true end host:touchSkinHotkey('fast_forward_toggle',true) T.eq(host.writes,1,'locked state blocks speed preference changes') +-- The offered native host must supply the actual lock owner; a test-only +-- speedLocked double cannot prove that a runtime skin action works. +host.speedLocked=nil +host.stack={states={{isFixedSpeed=true},{name='options'}}} +T.eq(host:speedLocked(),true,'real native lock sees fixed-speed state under menu') +host.speedOverride=4 +T.eq(host:logicSpeed(),1,'real lock overrides held acceleration in logic clock') +host:touchSkinHotkey('fast_forward_toggle',true) +T.eq(host.writes,1,'real lock blocks skin toggle persistence') +host.stack.states={};host.linkSession={} +T.eq(host:speedLocked(),true,'real native lock sees live link session') +host.linkSession=nil +T.eq(host:speedLocked(),false,'settled unlocked host can accelerate') +host:touchSkinHotkey('fast_forward_toggle',true) +T.eq(host.writes,2,'skin toggle reaches actual unlocked native owner') T.finish('native custom-skin ownership, persistence, rendering and input') From 5805f056a4d90e28757ca7e9f5b8c135a7ed8172 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:20:38 +0100 Subject: [PATCH 136/187] Use the actual native stack in upstream skin lock contract --- tests/engine/touch_skin_native.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/engine/touch_skin_native.lua b/tests/engine/touch_skin_native.lua index fb6bda13e..3ad3d339b 100644 --- a/tests/engine/touch_skin_native.lua +++ b/tests/engine/touch_skin_native.lua @@ -207,7 +207,8 @@ T.eq(host.writes,1,'locked state blocks speed preference changes') -- The offered native host must supply the actual lock owner; a test-only -- speedLocked double cannot prove that a runtime skin action works. host.speedLocked=nil -host.stack={states={{isFixedSpeed=true},{name='options'}}} +host.stack=setmetatable({},{__index=Stack});host.stack:init() +host.stack:push({isFixedSpeed=true});host.stack:push({name='options'}) T.eq(host:speedLocked(),true,'real native lock sees fixed-speed state under menu') host.speedOverride=4 T.eq(host:logicSpeed(),1,'real lock overrides held acceleration in logic clock') From a3f0d34443c522ec91a2d9d8a0750bf3dd863fcc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:19:12 +0100 Subject: [PATCH 137/187] Retain required source and visible native launcher credits --- README.md | 8 ++++++++ src/import/RomImporter.lua | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4cd31e7c0..be0173327 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # gen2recomp +Based on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC +(https://github.com/bryanthaboi/gen1recomp). + +Contributions by Roxas2712 — https://github.com/Roxas2712. +Newly adapted touch-skin/studio code retains the original GPLv3/Section7 +terms in `licenses/GEN1RECOMP-TOUCH-SKINS.md`. Existing author rights and +earlier grants remain effective; see `NOTICE.md` and `LICENSE-ROXAS2712.md`. +

diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 90da0c712..ba6ff1473 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -5708,7 +5708,13 @@ function RomImporter:draw() if link.url==DONATE_URL then paypalLink=link else footerLinks[#footerLinks+1]=link end end - local creditH = self.hintFont:getHeight() + local creditText = 'Created by '..credit.by + ..'\nBased on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC' + ..'\n(https://github.com/bryanthaboi/gen1recomp)' + ..'\nContributions by Roxas2712 — https://github.com/Roxas2712' + local creditWidth = math.max(1, appW - 2 * padH) + local _, creditLines = self.hintFont:getWrap(creditText, creditWidth) + local creditH = #creditLines * self.hintFont:getHeight() local donateH = self.hintFont:getHeight() + 10 * s local chipsH = (#footerLinks > 0) and (8 * s + donateH) or 0 local footerH = 10 * s + bcgDH + 6 * s + warningH + 8 * s + creditH + chipsH + 12 * s @@ -6243,7 +6249,7 @@ function RomImporter:draw() love.graphics.setFont(self.hintFont) local creditY = warningY + warningH + 8 * s col(PAL.warning) - love.graphics.printf("Created by " .. credit.by, appX, creditY, appW, "center") + love.graphics.printf(creditText, appX + padH, creditY, creditWidth, "center") self.donateButtons = {} self.donateButton = nil if #footerLinks > 0 then From 1c6d88c9e11345057e4d3492f728b06b57100c36 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:26:21 +0100 Subject: [PATCH 138/187] Apply native GSC move priority before Speed and expose the same model query --- src/battle/Gen2MovePriority.lua | 23 ++++++++ src/battle/TurnOrder.lua | 3 + src/battle/gen2/Battle.lua | 4 ++ tests/engine/gen2_move_priority.lua | 90 +++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 src/battle/Gen2MovePriority.lua create mode 100644 tests/engine/gen2_move_priority.lua diff --git a/src/battle/Gen2MovePriority.lua b/src/battle/Gen2MovePriority.lua new file mode 100644 index 000000000..9f9ac43c5 --- /dev/null +++ b/src/battle/Gen2MovePriority.lua @@ -0,0 +1,23 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- pokecrystal GetMovePriority/data/moves/effects_priorities.asm. Older native +-- caches only stamp priority-hit moves; derive the other GSC classes from +-- their native effect rather than treating Protect/Endure as ordinary moves. +local Priority = {} +local effects = { + PROTECT_EFFECT = 3, ENDURE_EFFECT = 3, + COUNTER_EFFECT = -1, MIRROR_COAT_EFFECT = -1, + FORCE_SWITCH_EFFECT = -1, +} +local ids = { + QUICK_ATTACK = 1, COUNTER = -1, VITAL_THROW = -1, + -- The shared native cache merges Roar/Whirlwind and Teleport into + -- SWITCH_AND_TELEPORT_EFFECT. Teleport is not a negative-priority move. + ROAR = -1, WHIRLWIND = -1, +} +function Priority.get(move) + if not move then return 0 end + -- Preserve explicit cartridge/mod data, including an explicit zero. + if move.priority then return move.priority end + return effects[move.effect] or ids[move.id] or 0 +end +return Priority diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index 1ebd0c231..1cd1a6cf2 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -66,6 +66,9 @@ end local PRIORITY = { QUICK_ATTACK = 1, COUNTER = -1 } local function priority(move) + if require('src.core.GameVersion').isGen2() then + return require('src.battle.Gen2MovePriority').get(move) + end if not move then return 0 end if move.priority then return move.priority end return PRIORITY[move.id] or 0 diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 8f00ffd0f..69149a57f 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -27,6 +27,10 @@ function Model:moveDef(move) return owner(self):moveDef(move) end +function Model:movePriority(id) + return require('src.battle.Gen2MovePriority').get(self:moveDef(id)) +end + function Model:speciesDef(mon) return mon and self.data.pokemon and self.data.pokemon[mon.species] or nil end diff --git a/tests/engine/gen2_move_priority.lua b/tests/engine/gen2_move_priority.lua new file mode 100644 index 000000000..3538a9bcc --- /dev/null +++ b/tests/engine/gen2_move_priority.lua @@ -0,0 +1,90 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- GSC's GetMovePriority precedes Speed. Exercise the shared comparator, the +-- actual native queued turn, and the public live-model query together. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Order=require('src.battle.TurnOrder') +local Save=require('src.core.SaveData') +local saved=Version.get() +local cases={ + {id='QA_PROTECT',effect='PROTECT_EFFECT',priority=3}, + {id='QA_ENDURE',effect='ENDURE_EFFECT',priority=3}, + {id='QA_COUNTER',effect='COUNTER_EFFECT',priority=-1}, + {id='QA_MIRROR',effect='MIRROR_COAT_EFFECT',priority=-1}, + {id='VITAL_THROW',effect='ALWAYS_HIT_EFFECT',priority=-1}, + {id='ROAR',effect='SWITCH_AND_TELEPORT_EFFECT',priority=-1}, + {id='WHIRLWIND',effect='SWITCH_AND_TELEPORT_EFFECT',priority=-1}, + {id='TELEPORT',effect='SWITCH_AND_TELEPORT_EFFECT',priority=0}, +} +local function fixture(version,chosen) + Version.set(version) + local data=T.fixtures.fresh();data.constants.gen=2 + for _,row in ipairs(cases)do + data.moves[row.id]={id=row.id,name=row.id,effect=row.effect,power=0,type='NORMAL',accuracy=100,pp=10} + end + local run=T.sdk.loadNone({data=data}) + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',25)} + save.party[1].moves={{id=chosen or 'QA_PROTECT',pp=10}} + local game={data=data,save=save,stack={top=function()end}} + local b=Screen.newWild(game,'FIXMON_B',25) + b.rng=function(lo)return lo end + return b,run +end +for _,version in ipairs{'gold','silver','crystal'}do + local b,run=fixture(version) + local a,z=b.player,b.enemy + local plain=b.data.moves.FIX_TACKLE + for _,row in ipairs(cases)do + local move=b.data.moves[row.id] + a.curStats.speed=row.priority>0 and 1 or 999 + z.curStats.speed=row.priority>0 and 999 or 1 + local expect=row.priority>=0 + T.eq(Order.firstMover(a,move,z,plain,b.rng,false,b,b.data),expect, + version..' native effect priority for '..row.id) + T.eq(b.battle:movePriority(row.id),row.priority,'public live-model priority agrees: '..row.id) + end + local explicit={id='QA_EXPLICIT',effect='PROTECT_EFFECT',priority=-7} + T.eq(Order.firstMover(a,explicit,z,plain,b.rng,false,b,b.data),false,'explicit native mod priority still wins') + b.data.moves[explicit.id]=explicit + T.eq(b.battle:movePriority(explicit.id),-7,'model uses the same explicit override') + T.eq(b.battle:movePriority('UNKNOWN_NATIVE_MOVE'),0,'missing priority stays neutral') + run.release() + for _,id in ipairs{'QA_PROTECT','QA_ENDURE'}do + local battle,owner=fixture(version,id) + battle.player.curStats.speed=1;battle.enemy.curStats.speed=999 + local selected=battle.player.curMoves[1] + local enemyMove=battle.enemy.curMoves[1] + -- Fix only the legal AI choice; the turn planner, queue closures, move + -- execution, PP and protection handlers below are the genuine owners. + battle.enemyAction=function()return enemyMove end + battle.queue={} + local hp=battle.player.mon.hp + battle:resolveTurn(selected) + local first,second=battle.queue[1],battle.queue[2] + T.check(first and second and first.fn and second.fn,'native turn queued both actual actions') + battle.nextInsert=0;first.fn();battle.nextInsert=nil + local field=id=='QA_PROTECT'and'protecting'or'enduring' + T.eq(battle.player[field],true,'slow '..id..' really executes before the faster enemy') + T.eq(selected.pp,9,'actual first move spends one PP') + if id=='QA_PROTECT'then + battle.nextInsert=0;second.fn();battle.nextInsert=nil + T.eq(battle.player.mon.hp,hp,'faster enemy attack is blocked by the actual protection state') + end + owner.release() + end +end +-- The added effect fallback belongs only to the GSC controller. Preserve +-- other generations' explicit imported priorities and legacy ID behavior. +for _,version in ipairs{'red','firered','emerald','heartgold','black'}do + Version.set(version) + local a={curStats={speed=1},stages={},mon={hp=100,stats={speed=1}}} + local z={curStats={speed=999},stages={},mon={hp=100,stats={speed=999}}} + T.eq(Order.firstMover(a,{id='QA_OTHER',effect='PROTECT_EFFECT'},z,{id='PLAIN'},function(lo)return lo end),false, + version..' retains its own priority contract') +end +Version.set(saved) +T.finish('native GSC priority, live model queries and actual protection turns') From 7cbf55e14eaf54faf71f697eccf5033804f2fc7a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:29:02 +0100 Subject: [PATCH 139/187] Document native GSC priority classes and real queued-action coverage --- docs/native-gen2-move-priority.md | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/native-gen2-move-priority.md diff --git a/docs/native-gen2-move-priority.md b/docs/native-gen2-move-priority.md new file mode 100644 index 000000000..235fd9692 --- /dev/null +++ b/docs/native-gen2-move-priority.md @@ -0,0 +1,32 @@ +# Native GSC move priority + +Older native GSC caches mark priority-hit moves explicitly, but leave Protect, +Endure, Mirror Coat and other priority classes without a `priority` field. +The shared comparator then treated them as ordinary moves. A slow Pokemon +could take damage before its Protect action; the live Gen2 model also reported +neutral priority for these moves. + +`Gen2MovePriority` supplies the cartridge's missing priority classes at the +actual native comparator and live-model query. Protect/Endure use +3; +Counter/Mirror Coat and Vital Throw use -1. Roar/Whirlwind use -1 by their +native IDs because the existing cache combines their effect with Teleport; +Teleport remains neutral. Explicit native cartridge/mod priority values, +including zero, take precedence. Other generations retain their own rules. +This does not require a re-import, change a move record or registry fingerprint, +or replace the native battle engine. It adds no RNG draw. + +The reference is the GSC `GetMovePriority`/effect-priority table also represented +by the read-only Gen1Recomp Gen2 model at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. This is an implementation in the +native shared controller, not an alias to a second combat simulator. + +The exact old-source regression reproduces 57 failed assertions, including +actual HP loss before slow Protect. The corrected 83-check contract exercises +Gold/Silver/Crystal, renamed effect-equivalent moves, explicit overrides, +unknown moves and the public model. It executes the real queued native actions +and verifies Protect/Endure state and PP; the enemy's real attack is blocked +after Protect. Cross-generation comparator checks and existing battle/model, +queue and held-item regressions remain separate checks. These ROM-free fixtures +are not a claim of complete move parity, unchanged-mod story/window behavior, +or release acceptance. Full model-constructor and other Gen1 behavior gaps +remain open. From 0a90e59ad53c228052645acb9ed149bd6e68f895 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:18:45 +0100 Subject: [PATCH 140/187] Expose real native GSC volatile ownership and retire transient model views --- src/battle/BattleState.lua | 7 +- src/battle/gen2/Battle.lua | 29 ++++++ tests/engine/gen2_volatile_state.lua | 133 +++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 tests/engine/gen2_volatile_state.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9c683585c..af74797ef 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -8304,9 +8304,12 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- FURY CUTTER and ROLLOUT double for each consecutive LANDING and reset -- the same way. Both counters live on the user and are cleared here, -- which is the one place every move passes through. - if move.id ~= "PROTECT" and move.id ~= "DETECT" and move.id ~= "ENDURE" then - user.protectRun = nil + local keepsProtection = move.id == "PROTECT" or move.id == "DETECT" or move.id == "ENDURE" + if GameVersion.isGen2() then + -- The GSC counter follows the effect, including renamed source moves. + keepsProtection = move.effect == "PROTECT_EFFECT" or move.effect == "ENDURE_EFFECT" end + if not keepsProtection then user.protectRun = nil end if user.rampMove and user.rampMove ~= move.id then user.rampRun, user.rampMove = nil, nil end diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 69149a57f..65b8946ab 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -16,10 +16,39 @@ local function owner(model) end local function active(screen, mon) + if screen.player == mon or screen.enemy == mon then return mon end if screen.player and screen.player.mon == mon then return screen.player end if screen.enemy and screen.enemy.mon == mon then return screen.enemy end end +local volatileNames={substitute='substituteHP',protect='protecting',mist='mist', + confusion='confused',confusionTurns='confusionTurns',flinch='flinched', + -- Canonical source GSC fields must drive the actual shared controller. + confuseCount='confusedTurns',flinched='flinched',endure='enduring', + recharge='mustRecharge',protectCount='protectRun', + focusEnergy='focusEnergy',xAccuracy='xAccuracy'} +function Model:volatile(mon) + local screen=owner(self) + local b=assert(active(screen,mon),'volatile state requires an active battler') + -- A global weak-key cache still roots its battlers on LuaJIT when the + -- value's closures retain their key. Keep views on the transient model, + -- never on a serialized battler or party mon, and retire withdrawn owners. + local views=rawget(self,'_nativeVolatileViews') + if not views then views={};rawset(self,'_nativeVolatileViews',views)end + for previous in pairs(views)do + if previous~=screen.player and previous~=screen.enemy then views[previous]=nil end + end + if not views[b] then + local extensions={} + views[b]=setmetatable({}, { + __index=function(_,key)local field=volatileNames[key];if field then return b[field] end;return extensions[key]end, + __newindex=function(_,key,value)local field=volatileNames[key];if field then b[field]=value else extensions[key]=value end end, + }) + end + return views[b] +end + + function Model:moveDef(move) -- Older native screens take a move instance; scalar catalog queries belong -- to this model and must not pass an id through that older signature. diff --git a/tests/engine/gen2_volatile_state.lua b/tests/engine/gen2_volatile_state.lua new file mode 100644 index 000000000..a498fb18b --- /dev/null +++ b/tests/engine/gen2_volatile_state.lua @@ -0,0 +1,133 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source GSC volatile names must reach the actual native status/damage owner, +-- not a disconnected metadata table. All monsters below are ROM-free fixtures. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Model=require('src.battle.gen2.Battle') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Damage=require('src.battle.Damage') +local oldVersion=V.get() +local function fixture(version,chosen,effect) + V.set(version) + local data=T.fixtures.fresh();data.constants.gen=2;data.constants.generation=2 + for _,id in ipairs{'PROTECT','QA_GUARD'}do + data.moves[id]={id=id,name=id,effect=effect or'PROTECT_EFFECT',power=0,type='NORMAL',accuracy=100,pp=10} + end + local run=T.sdk.loadMods({'mods/volatile_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/volatile_probe/manifest.json']='{"id":"volatile_probe","name":"Owned state probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/volatile_probe/main.lua']=[[ + local mod=... + local Model=require('src.battle.gen2.Battle') + mod.exports.write=function(model,mon,key,value)Model.volatile(model,mon)[key]=value end + mod.exports.read=function(model,mon,key)return Model.volatile(model,mon)[key]end + ]], + })}) + T.eq(#run.errors,0,'actual API2 source consumer loads') + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',30)} + if chosen then save.party[1].moves={{id=chosen,pp=10}}end + local game={data=data,save=save,stack={top=function()end}} + local b=Screen.newWild(game,'FIXMON_C',30) + local rolls=0;b.rng=function(lo,hi)rolls=rolls+1;return hi or lo end + return b,run,run.loader.exports.volatile_probe,function()return rolls end +end +local fields={ + {'flinched','flinched',true}, {'confuseCount','confusedTurns',3}, + {'endure','enduring',true}, {'recharge','mustRecharge',true}, + {'protectCount','protectRun',2}, {'focusEnergy','focusEnergy',true}, + {'xAccuracy','xAccuracy',true}, {'substitute','substituteHP',17}, + {'protect','protecting',true}, {'mist','mist',true}, +} +for _,version in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + local b,run,probe,rolls=fixture(version) + local model,mon=b.battle,b.player.mon + local view=model:volatile(mon) + T.eq(view,model:volatile(mon),'one stable source view for the same active battler') + T.eq(view,model:volatile(b.player),'party and native-wrapper queries share the owner') + for _,row in ipairs(fields)do + probe.write(model,mon,row[1],row[3]) + T.eq(b.player[row[2]],row[3],version..' source write reaches native '..row[2]) + b.player[row[2]]=nil + T.eq(probe.read(model,mon,row[1]),nil,'source read sees native clear: '..row[1]) + b.player[row[2]]=row[3] + T.eq(probe.read(model,mon,row[1]),row[3],'native state reaches source read: '..row[1]) + probe.write(model,mon,row[1],nil) + T.eq(b.player[row[2]],nil,'source clear retires native state: '..row[1]) + T.eq(mon[row[1]],nil,'transient source field is not written into party/save') + end + probe.write(model,mon,'flinched',true) + local pp=mon.moves[1].pp + T.eq(b:statusInterrupt(b.player,b.enemy,{id='FIX_TACKLE'}),true,'source flinch actually blocks native admission') + T.eq(not not probe.read(model,mon,'flinched'),false,'native admission clears source flinch') + T.eq(rolls(),0,'flinch does not consume battle RNG') + T.eq(mon.moves[1].pp,pp,'blocked admission cannot spend PP') + probe.write(model,mon,'confuseCount',2) + T.eq(b:statusInterrupt(b.player,b.enemy,{id='FIX_TACKLE'}),false,'high native roll allows a confused attack') + T.eq(b.player.confusedTurns,1,'actual native confusion decrements source counter') + T.eq(probe.read(model,mon,'confuseCount'),1,'source sees the actual remaining counter') + T.eq(rolls(),1,'confusion consumes exactly one actual byte roll') + probe.write(model,mon,'confuseCount',nil) + probe.write(model,mon,'recharge',true) + local locked=b:lockedAction(b.player) + T.eq(locked and locked.special,'recharge','source recharge reaches the real move lock') + probe.write(model,mon,'recharge',nil) + local hp=mon.hp + probe.write(model,mon,'endure',true) + b:applyDamage(b.player,100000,true) + T.eq(mon.hp,1,'source Endure caps actual incoming damage at one HP') + mon.hp=hp + b:applyDamage(b.player,100000,false) + T.eq(mon.hp,0,'Endure does not absorb native residual damage') + probe.write(model,mon,'endure',nil);mon.hp=hp + local low={id='QA_LOW_ACCURACY',accuracy=1,type='NORMAL'} + local function accuracy()return Damage.accuracyRoll({oneIn256Miss=true},low,b.player,b.enemy,function()return 255 end,nil,b.data)end + T.eq(accuracy(),false,'low accuracy misses before source override') + probe.write(model,mon,'xAccuracy',true);T.eq(accuracy(),true,'source X Accuracy bypasses the real hit check') + probe.write(model,mon,'xAccuracy',nil) + local function critical()return Damage.critRoll({},b.player,'FIX_TACKLE',function()return 20 end,false,b.data,b)end + T.eq(critical(),false,'fixed byte is outside the native base critical stage') + probe.write(model,mon,'focusEnergy',true);T.eq(critical(),true,'source Focus Energy reaches native GSC critical stage') + probe.write(model,mon,'focusEnergy',nil) + local marker={owned=true};view._qaPresentation=marker + collectgarbage('collect');T.eq(model:volatile(mon)._qaPresentation,marker,'consumer metadata persists while active') + local old=b.player + b:switchPlayerInto(Screen.POS.PLAYER_LEFT,b.game.save.party[2]) + local nextView=model:volatile(model.player) + T.check(nextView~=view,'new native wrapper has a new volatile owner') + T.eq(nextView._qaPresentation,nil,'source metadata does not leak to incoming actor') + probe.write(model,model.player,'confuseCount',4) + T.eq(b.player.confusedTurns,4,'incoming native battler owns the new source state') + T.eq(old.confusedTurns,nil,'new state cannot change the withdrawn native wrapper') + T.eq(mon.volatile,nil,'source view cannot create persistent party volatile data') + run.release() + for _,chosen in ipairs{'PROTECT','QA_GUARD'}do + local battle,owner,source=fixture(version,chosen) + source.write(battle.battle,battle.player.mon,'protectCount',2) + battle:performMove(battle.player,battle.enemy,battle.player.curMoves[1],false,true) + T.eq(battle.player.protecting,nil,'native repeated '..chosen..' fails its real odds roll') + T.eq(source.read(battle.battle,battle.player.mon,'protectCount'),0,'native failed protection clears source counter') + owner.release() + end + local battle,owner,source=fixture(version,'PROTECT','NO_ADDITIONAL_EFFECT') + source.write(battle.battle,battle.player.mon,'protectCount',2) + battle:performMove(battle.player,battle.enemy,battle.player.curMoves[1],false,true) + T.eq(source.read(battle.battle,battle.player.mon,'protectCount'),nil,'non-protection effect resets run even with old PROTECT ID') + owner.release() +end +-- A module-global weak-key map whose value closure retains its key is not +-- collectible on LuaJIT's Lua5.1 collector. No caller-held view remains here. +local weak=setmetatable({},{__mode='k'}) +local function retired() + local mon={};local b={mon=mon};local screen={player=b};local model={} + Model.bind(model,screen);Model.volatile(model,mon)._owned=true + T.eq(screen.player,b,'owner stays live during its actual view creation') + weak[b]=true +end +for _=1,12 do retired()end +collectgarbage('collect');collectgarbage('collect') +T.eq(next(weak),nil,'module cache cannot retain retired battlers through view closures') +V.set(oldVersion) +T.finish('native source volatile state, real mechanics, switch and cache lifetime') From b7b990c898900bc2aacdf9250d388d35034cfe8a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:25:35 +0100 Subject: [PATCH 141/187] Document native volatile bridge scope and independently tested older host --- docs/native-gen2-volatile-state.md | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/native-gen2-volatile-state.md diff --git a/docs/native-gen2-volatile-state.md b/docs/native-gen2-volatile-state.md new file mode 100644 index 000000000..33fd651b3 --- /dev/null +++ b/docs/native-gen2-volatile-state.md @@ -0,0 +1,34 @@ +# Native GSC volatile state + +The live Gen2 battle model exposes `volatile(mon)` as a view of its existing +native battler, rather than a disconnected metadata table. Canonical source +names `confuseCount`, `flinched`, `endure`, `recharge`, `protectCount`, +`focusEnergy` and `xAccuracy` address the native confusion counter, admission, +damage, action-lock, protection, critical and hit-check owners. Substitute, +protect and mist aliases are also supported. Unknown keys remain transient +consumer metadata, not newly implemented mechanics. + +Views are cached on the transient model only. Module-global weak-key caches can +retain retired battlers on LuaJIT when value closures reference the key. The +same active native owner has a stable view; withdrawn entries retire on query +and incoming wrappers get new views. No view or metadata is stored in a party +mon or serialized battler. + +GSC consecutive-protection retention follows the actual PROTECT/ENDURE effect, +including renamed moves; an old PROTECT ID with another effect resets it. +Protection RNG, opponent-first and Substitute rules remain separate open +mechanics work. No second battle simulator is introduced. + +On this older host, 423 real native state/mechanics/ownership/collection checks, +262 model checks and 83 move-priority checks pass independently. The fixture +loads an actual API2 source consumer and covers Gold/Silver/Crystal, Prism and +Polished Crystal. The fork's additional semantic-checkpoint backend is absent +here, so its checkpoint result is not claimed for this offered branch. +These ROM-free CPU checks do not establish complete source-model, GPU, +unchanged-mod or release acceptance. Depends on the native model boundary and +move-priority contributions offered separately. + +Source-field names were inspected against Gen1Recomp Gen2 revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Native integration and tests are +contributions by Roxas2712, offered to Cedric / UNDERdecoded under the fork's +specific PR contribution permission, with existing third-party rights intact. From 2a3d02111e845484c151b9f6f746bf63dad093ae Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:38:01 +0100 Subject: [PATCH 142/187] Run native GSC protection with actual first-side, RNG and link compatibility ownership --- src/battle/BattleState.lua | 1 + src/battle/EffectRegistry.lua | 3 + src/battle/Gen2Protection.lua | 30 ++++++++ src/battle/Gen3MoveEffects.lua | 4 ++ src/link/Fingerprint.lua | 8 ++- src/link/Handshake.lua | 8 +++ tests/engine/gen2_protection.lua | 99 +++++++++++++++++++++++++++ tests/engine/gen2_protection_link.lua | 64 +++++++++++++++++ 8 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 src/battle/Gen2Protection.lua create mode 100644 tests/engine/gen2_protection.lua create mode 100644 tests/engine/gen2_protection_link.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index af74797ef..d3a3c3a6d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -7723,6 +7723,7 @@ function BattleState:executeAction(user, target, action) if user.g4Gone then return end -- this battler's action is under way (Payback, Sucker Punch, Me First) user.actedTurn = self.turnCount + if GameVersion.isGen2() then require("src.battle.Gen2Protection").actionStarted(self, user) end -- A SWITCH NEEDS NO TARGET. Everything else does, and with four on the -- field the one it was aimed at can already be off it -- the entry's -- target is resolved as the entry runs, and there may be nobody left to diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 87a4bd83a..25c1f8600 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -44,6 +44,9 @@ EffectRegistry.displayName = displayName -- flags recorded (every Gen 1 and Gen 2 one) is affected, that generation's -- rule. function EffectRegistry.protectAffected(move) + -- GSC self-protection must reach its own restriction even if the foe protects. + if require('src.core.GameVersion').isGen2() + and (move.effect == 'PROTECT_EFFECT' or move.effect == 'ENDURE_EFFECT') then return false end if move.protectAffected ~= nil then return move.protectAffected ~= false end if move.gen4Effect ~= nil and tonumber(move.flags) then return math.floor(tonumber(move.flags) / 2) % 2 == 1 diff --git a/src/battle/Gen2Protection.lua b/src/battle/Gen2Protection.lua new file mode 100644 index 000000000..299260dc6 --- /dev/null +++ b/src/battle/Gen2Protection.lua @@ -0,0 +1,30 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- GSC ProtectChance: a nonzero byte minus one against FF >> prior uses. +-- Keep the existing native action, battler and RNG owners. +local Protection={} +local function side(user)return user.isPlayer and 'player' or 'enemy'end + +function Protection.actionStarted(battle,user) + local turn=battle.turnCount or 0 + if battle._gen2ProtectionTurn~=turn then + battle._gen2ProtectionTurn=turn;battle._gen2ProtectionFirst=nil + end + if not battle._gen2ProtectionFirst then battle._gen2ProtectionFirst=side(user)end +end + +function Protection.apply(battle,user,field) + local turn=battle.turnCount or 0 + local first=battle._gen2ProtectionTurn==turn and battle._gen2ProtectionFirst + local count=math.max(0,math.floor(tonumber(user.protectRun)or 0)) + local threshold=count<8 and math.floor(255/2^count)or 0 + -- These branches precede the draw. A called protection effect retains the + -- original round's first side, including after a native switch replaces it. + if (first and first~=side(user))or(user.substituteHP or 0)>0 + or threshold==0 or battle.rng(1,255)-1>=threshold then + user.protectRun=0 + return false + end + user.protectRun=count+1;user[field]=true + return true +end +return Protection diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index 1704c46d1..57c4f1e8c 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -284,6 +284,10 @@ end -- else, so a spammed PROTECT fails the way the cartridge makes it fail. local function protection(field, text) return function(battle, user) + if require("src.core.GameVersion").isGen2() then + if not require("src.battle.Gen2Protection").apply(battle, user, field) then return { Strings("But, it failed!") } end + return { Strings(text, displayName(user)) } + end local run = user.protectRun or 0 -- 1/1, then 1/2, 1/4, 1/8... A run of four is already a coin flip -- three times over, which is what stops it being a stall lock. diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 699eb68ef..fd8654fb8 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -17,6 +17,10 @@ local Runtime = require("src.mods.Runtime") local Fingerprint = {} +-- Command/RNG compatibility is independent of byte-identical ROM data. +function Fingerprint.nativeBattleRevision() + return require('src.core.GameVersion').isGen2() and 'gsc-1' or nil +end -- ------- FNV-1a, two lanes @@ -238,6 +242,8 @@ local function surface(data, mods) writeSection(out, data, "move_effects") writeConstants(out, data) writeLinkFields(out, data) + local revision=Fingerprint.nativeBattleRevision() + if revision then out[#out+1]='[native_gsc_commands]'..revision end out[#out + 1] = "[mods]" .. modKey(mods) return table.concat(out) end @@ -247,7 +253,7 @@ Fingerprint.surface = surface -- mods: { { id, version, affectsLink } } -- the hello's mod array function Fingerprint.compute(data, mods) if not data then return digest("") end - local key = modKey(mods) + local key = modKey(mods).."|native_commands:"..tostring(Fingerprint.nativeBattleRevision()) local hit = cache[data] if hit and hit.key == key then return hit.value end local value = Runtime.call("link.fingerprint", function(d, m) diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index aec8437e7..dad7599c4 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -158,6 +158,7 @@ function Handshake.hello(game, mode) engineVersion = Version.engine, apiVersion = Version.modApi, fingerprint = Fingerprint.compute(game and game.data, mods), + nativeBattleRevision = Fingerprint.nativeBattleRevision(), linkModified = Handshake.linkModified(game), mods = mods, } @@ -174,6 +175,7 @@ end function Handshake.checkCompat(localHello, remoteHello) localHello = localHello or {} if not remoteHello or not remoteHello.protocol then + if localHello.nativeBattleRevision then return "refused", "native_rules_required" end if localHello.linkModified then return "refused", "peer_v1_modified" end @@ -182,6 +184,7 @@ function Handshake.checkCompat(localHello, remoteHello) if major(remoteHello.engineVersion) ~= major(localHello.engineVersion) then return "refused", "engine_mismatch" end + if localHello.nativeBattleRevision ~= remoteHello.nativeBattleRevision then return "subset", "native_rules_mismatch" end if remoteHello.fingerprint == localHello.fingerprint then return "full", nil end @@ -264,6 +267,11 @@ end function Handshake.describe(localHello, remoteHello, verdict, mode) local lines = {} local peer = (remoteHello and remoteHello.name) or "THEY" + if (localHello and localHello.nativeBattleRevision) ~= (remoteHello and remoteHello.nativeBattleRevision) then + wrap(lines, Strings("The native battle rules differ.")) + wrap(lines, Strings("Use matching engine builds for link battles.")) + return lines + end if verdict == "refused" then if not (remoteHello and remoteHello.protocol) then wrap(lines, "The other game is") diff --git a/tests/engine/gen2_protection.lua b/tests/engine/gen2_protection.lua new file mode 100644 index 000000000..65809af44 --- /dev/null +++ b/tests/engine/gen2_protection.lua @@ -0,0 +1,99 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native registered protection effects, RNG boundaries and real queued order. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local P=require('src.battle.Gen3MoveEffects').primary +local before=V.get() +local function fixture(edition) + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=V.generation() + data.moves.QA_PROTECT={id='QA_PROTECT',name='QA PROTECT',power=0,type='NORMAL',accuracy=100,pp=20,effect='PROTECT_EFFECT'} + data.moves.QA_ENDURE={id='QA_ENDURE',name='QA ENDURE',power=0,type='NORMAL',accuracy=100,pp=20,effect='ENDURE_EFFECT'} + local run=T.sdk.loadNone({data=data});require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',30)} + save.party[1].moves={{id='QA_PROTECT',pp=20}} + local b=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30) + return b,run +end +for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + local b,run=fixture(edition) + for _,effect in ipairs{{'PROTECT_EFFECT','protecting'},{'ENDURE_EFFECT','enduring'}}do + for count=0,9 do + local threshold=math.floor(255/2^count) + for _,byte in ipairs{0,1,62,63,126,127,253,254}do + local draws,loSeen,hiSeen=0 + b.rng=function(lo,hi)draws=draws+1;loSeen,hiSeen=lo,hi;return lo+byte%(hi-lo+1)end + b.player.protectRun=count;b.player[effect[2]]=nil + P[effect[1]](b,b.player) + local succeeds=threshold>0 and byte0 and 1 or 0,'one nonzero-byte draw until threshold reaches zero') + if threshold>0 then + T.eq(loSeen,1,'native draw starts at one') + T.eq(hiSeen,255,'native draw spans all nonzero bytes') + end + end + end + for _,reason in ipairs{'substitute','opponent-first'}do + b.player.protectRun=3;b.player[effect[2]]=nil + b.player.substituteHP=reason=='substitute'and 10 or nil + b.turnCount=10;b._gen2ProtectionTurn=10 + b._gen2ProtectionFirst=reason=='opponent-first'and'enemy'or'player' + local draws=0;b.rng=function(lo)draws=draws+1;return lo end + P[effect[1]](b,b.player) + T.eq(b.player[effect[2]],nil,reason..' prevents '..effect[1]) + T.eq(b.player.protectRun,0,reason..' resets actual counter') + T.eq(draws,0,reason..' fails before the random draw') + end + b.player.substituteHP=nil;b._gen2ProtectionTurn=nil;b._gen2ProtectionFirst=nil + end + -- Both sides choose the same priority. The slower user's actual queued + -- action must fail after the faster opponent has acted, without a draw. + b.player.curStats.speed=1;b.enemy.curStats.speed=999 + local selected=b.player.curMoves[1] + b.enemy.curMoves[1]={id='QA_ENDURE',pp=20} + b.enemyAction=function()return b.enemy.curMoves[1]end + b.queue={};local draws=0;b.rng=function(lo)draws=draws+1;return lo end + b:resolveTurn(selected) + local first,second=b.queue[1],b.queue[2] + T.check(first and second and first.fn and second.fn,'real native actions are queued') + b:runQueueAction(first) + T.eq(b.enemy.enduring,true,'the faster opponent executes actual Endure first') + local afterFirst=draws + b:runQueueAction(second) + T.eq(b.player.protecting,nil,'second actor cannot Protect after opponent') + T.eq(b.player.protectRun,0,'second actor resets source-readable counter') + T.eq(draws,afterFirst,'opponent-first failure consumes no protection RNG') + T.eq(selected.pp,19,'failed protection still spends the actual move PP') + -- A new round retires the previous first actor. Called effects remain in + -- the same round and cannot change its first side. + b:beginTurn();b:executeAction(b.player,b.enemy,{special='recharge'}) + T.eq(b._gen2ProtectionFirst,'player','next round records its actual first side') + -- First-side identity must survive replacement of that side's wrapper. + b:beginTurn() + local withdrawn=b.player + b:executeAction(withdrawn,b.enemy,{special='playerSwitch',mon=b.game.save.party[2]}) + T.check(b.player~=withdrawn,'real native switch replaces the first wrapper') + b.enemy.protectRun=2 + b:executeAction(b.enemy,b.player,{id='QA_ENDURE',pp=20}) + T.eq(b.enemy.enduring,nil,'second actor cannot Endure after opponent switches') + T.eq(b.enemy.protectRun,0,'switch-first failure resets actual counter') + run.release() +end +-- Existing Gen1/GBA behavior is deliberately untouched by the GSC owner. +for _,edition in ipairs{'red','firered','emerald'}do + local b,run=fixture(edition);local draws=0 + b.rng=function(lo)draws=draws+1;return lo end + b.player.substituteHP=10;b._gen2ProtectionTurn=b.turnCount;b._gen2ProtectionFirst='enemy' + P.PROTECT_EFFECT(b,b.player) + T.eq(b.player.protecting,true,edition..' retains its previous protection behavior') + T.eq(draws,0,edition..' first use retains its old random protocol') + run.release() +end +V.set(before) +T.finish('native GSC protection thresholds, restrictions, order, PP and RNG') diff --git a/tests/engine/gen2_protection_link.lua b/tests/engine/gen2_protection_link.lua new file mode 100644 index 000000000..f39e734c5 --- /dev/null +++ b/tests/engine/gen2_protection_link.lua @@ -0,0 +1,64 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Actual participant/spectator queues must share GSC order and RNG ownership. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Copy=require('src.mods.Merge').deepCopy +local H=require('src.link.Handshake') +local P,N,L=require('src.link.Protocol'),require('src.link.Net'),require('src.link.LinkBattle') +local old=V.get() +for _,edition in ipairs{'gold','silver','crystal'}do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.QA_PROTECT={id='QA_PROTECT',name='QA PROTECT',power=0,type='NORMAL',accuracy=100,pp=20,effect='PROTECT_EFFECT'} + local run=T.sdk.loadNone({data=data});T.link.prepare(data) + local games={} + for i=1,3 do + local game=T.link.fakeGame(data,'FIXMON_A',{name='ROLE'..i,level=30}) + game.save.version=edition;game.save.party[1].moves={{id='QA_PROTECT',pp=20}} + games[i]=game + end + local hello=H.hello(games[1],'battle');local legacy=Copy(hello);legacy.nativeBattleRevision=nil + T.eq(hello.nativeBattleRevision,'gsc-1','GSC advertises changed command/RNG semantics') + T.eq(H.checkCompat(hello,legacy),'subset','legacy GSC cannot enter changed lockstep') + T.eq(H.checkCompat(legacy,hello),'subset','GSC command mismatch is symmetric') + for _,host in ipairs{false,true}do + local state=setmetatable({game=games[1],myHello=hello,peerHello=legacy},{__index=require('src.link.LinkState')}) + state:decideCompat('battle',host) + T.eq(state.stage,'notice','actual admission refuses old commands before exchange') + end + local na,nb=N.loopbackPair();local inbox={} + local sn={update=function()end,poll=function()local packets=inbox;inbox={};return packets end} + for i,net in ipairs{na,nb}do + local send,side=net.send,i==1 and'host'or'guest' + net.send=function(self,packet)inbox[#inbox+1]={type='spectate',side=side,msg=Copy(packet)};return send(self,packet)end + end + local pa,pb=P.packParty(games[1].save.party),P.packParty(games[2].save.party) + local a=assert(L.newHost(games[1],na,{myParty=pa,theirParty=pb,theirName='GUEST',seed=327,verdict='full',strict=true})) + local b=assert(L.newGuest(games[2],nb,{myParty=pb,theirParty=pa,theirName='HOST',seed=327,verdict='full',strict=true})) + local s=assert(L.newSpectator(games[3],sn,{hostParty=pa,guestParty=pb,hostName='HOST',guestName='GUEST',seed=327,verdict='full',strict=true})) + local roles={a,b,s} + for i,battle in ipairs(roles)do games[i].stack:push(battle)end + for _=1,20000 do + for i,game in ipairs(games)do + require('src.core.Input').pressed=(i==3 or not roles[i].localHashes[1])and{a=true}or{} + game.stack:update(1/60) + end + if a.localHashes[1]and b.localHashes[1]and s.turnCount==1 and s.phase=='waitBoth'then break end + end + T.check(a.localHashes[1]~=nil and b.localHashes[1]~=nil,'both actual participant queues complete') + T.eq(a.localHashes[1],b.localHashes[1],'GSC protection RNG and state hashes agree') + T.same(a.localParts[1],b.localParts[1],'all completed native state components agree') + for _,battle in ipairs(roles)do + local x,y=battle.player.protectRun,battle.enemy.protectRun + T.check(x==1 and y==0 or x==0 and y==1,'only actual first actor succeeds; second clears its counter') + T.eq(battle.player.mon.moves[1].pp,19,'own first-round move spends PP exactly once') + T.eq(battle.enemy.mon.moves[1].pp,19,'other first-round move spends PP exactly once') + T.eq(battle.result,nil,'no hidden participant/spectator error') + end + T.eq(s.player.protectRun,a.player.protectRun,'spectator preserves host protection owner') + T.eq(s.enemy.protectRun,a.enemy.protectRun,'spectator preserves guest protection owner') + run.release() +end +V.set(old) +T.finish('native GSC protection participant, spectator and protocol admission') From eead513582022f65e6d73a37bf5bf59b34bb98a8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:38:28 +0100 Subject: [PATCH 143/187] Use the existing string owner for native rules mismatch messages --- src/link/Handshake.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index dad7599c4..cec478157 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -10,6 +10,7 @@ local Fingerprint = require("src.link.Fingerprint") local Schemas = require("src.mods.Schemas") local Version = require("src.core.Version") +local Strings = require("src.core.Strings") local Handshake = {} From dbabc4ea744d7f728c8dfda4f6eca8e1b7c9be72 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:40:30 +0100 Subject: [PATCH 144/187] Exercise actual older-host queue entries and audited GSC protocol transitions --- tests/engine/gen2_protection.lua | 8 +++---- tests/engine/gen2_protection_protocol.lua | 27 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 tests/engine/gen2_protection_protocol.lua diff --git a/tests/engine/gen2_protection.lua b/tests/engine/gen2_protection.lua index 65809af44..af5440e26 100644 --- a/tests/engine/gen2_protection.lua +++ b/tests/engine/gen2_protection.lua @@ -62,20 +62,20 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do b:resolveTurn(selected) local first,second=b.queue[1],b.queue[2] T.check(first and second and first.fn and second.fn,'real native actions are queued') - b:runQueueAction(first) + b.nextInsert=0;first.fn() T.eq(b.enemy.enduring,true,'the faster opponent executes actual Endure first') local afterFirst=draws - b:runQueueAction(second) + b.nextInsert=0;second.fn() T.eq(b.player.protecting,nil,'second actor cannot Protect after opponent') T.eq(b.player.protectRun,0,'second actor resets source-readable counter') T.eq(draws,afterFirst,'opponent-first failure consumes no protection RNG') T.eq(selected.pp,19,'failed protection still spends the actual move PP') -- A new round retires the previous first actor. Called effects remain in -- the same round and cannot change its first side. - b:beginTurn();b:executeAction(b.player,b.enemy,{special='recharge'}) + b.queue={};b:resolveTurn(b.player.curMoves[1]);b:executeAction(b.player,b.enemy,{special='recharge'}) T.eq(b._gen2ProtectionFirst,'player','next round records its actual first side') -- First-side identity must survive replacement of that side's wrapper. - b:beginTurn() + b.queue={};b:resolveTurn(b.player.curMoves[1]) local withdrawn=b.player b:executeAction(withdrawn,b.enemy,{special='playerSwitch',mon=b.game.save.party[2]}) T.check(b.player~=withdrawn,'real native switch replaces the first wrapper') diff --git a/tests/engine/gen2_protection_protocol.lua b/tests/engine/gen2_protection_protocol.lua new file mode 100644 index 000000000..fa78cb78d --- /dev/null +++ b/tests/engine/gen2_protection_protocol.lua @@ -0,0 +1,27 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V,F,H=require('src.core.GameVersion'),require('src.link.Fingerprint'),require('src.link.Handshake') +local Copy=require('src.mods.Merge').deepCopy +local before=V.get();local data=T.fixtures.fresh() +-- Independently audited Python integer FNV: the old raw fixture surface, +-- with only [native_gsc_commands]gsc-1 inserted before its mods domain. +for _,edition in ipairs{'red','gold','firered','crystal','emerald','silver','red'}do + V.set(edition);local gsc=V.isGen2() + T.eq(F.compute(data,{}),gsc and '40df18dc503213c8' or 'e33d251961f2a41d','only GSC changes the older host fixture surface') + T.eq(F.nativeBattleRevision(),gsc and 'gsc-1'or nil,'only GSC advertises new native rules') + local hello=H.hello({data=data,save={player={name='RULES'}}},'battle') + T.eq(H.checkCompat(hello,Copy(hello)),'full','matching actual hello admits lockstep') + if gsc then + local old=Copy(hello);old.nativeBattleRevision=nil + T.eq(H.checkCompat(hello,old),'subset','equal-digest old rule field cannot enter lockstep') + T.eq(H.checkCompat(old,hello),'subset','rule mismatch remains symmetric') + T.eq(H.checkCompat(hello,{}),'refused','unversioned peer cannot assume changed GSC rules') + local lines=H.describe(hello,old,'subset','battle') + T.check(table.concat(lines,' '):find('native battle rules',1,true)~=nil,'visible mismatch describes native rules') + else + T.eq(H.checkCompat(hello,{}),'vanilla_peer','other old-host editions retain old wire behavior') + end +end +V.set(before) +T.finish('older-host native GSC fingerprint cache and actual protocol negotiation') From a8ca624288d3fd15f6e5f9cac601ab98fc3840af Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:41:22 +0100 Subject: [PATCH 145/187] Document independently qualified older-host protection and source reference scope --- docs/native-gen2-protection.md | 38 ++++++++++++++++++++++++++++++ docs/native-gen2-volatile-state.md | 5 ++-- 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 docs/native-gen2-protection.md diff --git a/docs/native-gen2-protection.md b/docs/native-gen2-protection.md new file mode 100644 index 000000000..e0348b7f6 --- /dev/null +++ b/docs/native-gen2-protection.md @@ -0,0 +1,38 @@ +# Native GSC Protect and Endure + +Run GSC protection on the real shared-controller battler, with the source +model's nonzero-byte odds (`255 >> prior successes`), first-side and Substitute +restrictions. Actual action entry records the first side for that round, +including switches and failed/recharge actions; called effects retain it. +The next round replaces it. GSC self-protection bypasses the generic incoming +status gate so the real handler can clear its failed-use counter. + +Changed command/RNG semantics advertise `gsc-1` in link hello and a separate +GSC fingerprint domain. Older rules cannot enter the changed lockstep battle; +the compatibility screen names the native rules difference. Other generations +retain their existing handlers and fingerprints. This older host does not +carry the fork's semantic-checkpoint backend, so no checkpoint result is +claimed here. + +References: read-only Gen1Recomp Gen2 revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, its protection handlers and +`Effects.protectSucceeds`; `pret/pokecrystal` +`5c877ef55197039c9676e8e3eec6d254b1c40fe8`, +`engine/battle/move_effects/protect.asm`. The nonzero-domain native RNG call +matches the source model convention; this does not emulate a hardware PRNG or +claim raw zero-rejection trace parity. + +The native model/volatile/priority prerequisites remain separate offered PRs. +Independent results on this offered host: 3796 native effect/queued-order/PP/ +switch checks, 66 host/guest/spectator and admission checks, 37 actual protocol/ +cache/digest checks, 423 volatile checks and 83 priority checks pass. The frozen +Gen1 comparison independently passes 30,624 actual effect cases / 165,336 +comparisons. A separate Python integer-FNV audit confirms this host's legacy +fixture digest e33d251961f2a41d and GSC digest 40df18dc503213c8. Tests drive this +older host's actual queue-row closures; they do not assume the newer fork's +runQueueAction or beginTurn helpers exist. Fork-only checkpoint and complete +release acceptance are not inherited. GPU, complete called-move/turn mechanics, +unchanged-mod gameplay and full Gen1 feature parity remain separate work. + +Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under +the fork's specific PR contribution permission, with existing rights intact. diff --git a/docs/native-gen2-volatile-state.md b/docs/native-gen2-volatile-state.md index 33fd651b3..32fdaf375 100644 --- a/docs/native-gen2-volatile-state.md +++ b/docs/native-gen2-volatile-state.md @@ -16,8 +16,9 @@ mon or serialized battler. GSC consecutive-protection retention follows the actual PROTECT/ENDURE effect, including renamed moves; an old PROTECT ID with another effect resets it. -Protection RNG, opponent-first and Substitute rules remain separate open -mechanics work. No second battle simulator is introduced. +Protection RNG, opponent-first and Substitute rules are addressed by the +separate [native protection work](native-gen2-protection.md). +No second battle simulator is introduced. On this older host, 423 real native state/mechanics/ownership/collection checks, 262 model checks and 83 move-priority checks pass independently. The fixture From 5d8752a31c6e24e0498a06be565395095d7d234c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:02:46 +0100 Subject: [PATCH 146/187] Expose live source GSC RNG fields and roller over the older native owner --- src/battle/Gen2BattleView.lua | 2 + src/battle/gen2/Battle.lua | 24 ++++++- tests/engine/gen2_model_rng.lua | 116 ++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 tests/engine/gen2_model_rng.lua diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua index 06605b8e1..a5d6fe5bd 100644 --- a/src/battle/Gen2BattleView.lua +++ b/src/battle/Gen2BattleView.lua @@ -22,6 +22,8 @@ function View.bind(screen) enemyParty = function() return screen.enemyParty end, save = function() return screen.game.save end, data = function() return screen.data end, + rng = function() return rawget(screen.battle, '_nativeLoveRng') end, + random = function() return screen.battle:roller() end, wild = function() return screen.kind == "wild" end, playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 65b8946ab..c71bc500f 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -6,13 +6,33 @@ local Model = {} local screens = setmetatable({}, { __mode = 'kv' }) local ended = setmetatable({}, { __mode = 'k' }) +local function owner(model) + return assert(screens[model], 'native Gen2 model is not attached to a battle') +end + function Model.bind(model, screen) screens[model] = screen + -- Source GSC's LOVE-style view uses the current native function even if + -- link setup or a scoped native owner replaces it after this bind. + rawset(model, '_nativeLoveRng', function(lo, hi) + local rng = owner(model).rng + if hi == nil then return rng(math.max(1, lo or 1)) end + if hi < lo then lo, hi = hi, lo end + return rng(lo, hi) + end) return model end -local function owner(model) - return assert(screens[model], 'native Gen2 model is not attached to a battle') +function Model:roller() + owner(self) + local roller = rawget(self, '_nativeRoller') + if not roller then + -- Zero-based BattleRandom over the same existing stream. Keep the cache + -- on the transient model; a global value closure could root a weak key. + roller = function(n) return owner(self).rng(n) - 1 end + rawset(self, '_nativeRoller', roller) + end + return roller end local function active(screen, mon) diff --git a/tests/engine/gen2_model_rng.lua b/tests/engine/gen2_model_rng.lua new file mode 100644 index 000000000..b07ecddda --- /dev/null +++ b/tests/engine/gen2_model_rng.lua @@ -0,0 +1,116 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Actual source consumers must share the native stream, including after its +-- function is replaced. No shadow generator, draws on lookup or saved closures. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Model=require('src.battle.gen2.Battle') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local before=V.get() +for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + local run=T.sdk.loadMods({'mods/rng_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/rng_probe/manifest.json']='{"id":"rng_probe","name":"Native RNG probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/rng_probe/main.lua']=[[ + local mod=... + local Battle=require('src.battle.gen2.Battle') + mod.exports.love=function(model,lo,hi)return model.rng(lo,hi)end + mod.exports.cart=function(model,n)return model.random(n)end + mod.exports.method=function(model,n)return Battle.roller(model)(n)end + ]], + })}) + T.eq(#run.errors,0,'actual API2 source RNG consumer loads') + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',25)} + local b=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30) + local probe=run.loader.exports.rng_probe + local trace={} + local function stream(tag) + return function(lo,hi) + local first,last=lo,hi + if hi==nil then lo,hi=1,lo end + trace[#trace+1]={tag=tag,lo=first,hi=last} + return lo+(#trace*37)%(hi-lo+1) + end + end + local native=stream('initial');b.rng=native + local model=b.battle + T.eq(type(model.rng),'function',edition..' source LOVE RNG exists') + T.eq(type(model.random),'function','source zero-based random exists') + T.eq(type(model.roller),'function','source roller method exists') + if type(model.rng)=='function'and type(model.random)=='function'and type(model.roller)=='function'then + local loveRng,random,roller=model.rng,model.random,model:roller() + T.eq(model.rng,loveRng,'LOVE adapter identity is stable') + T.eq(model.random,random,'zero-based adapter identity is stable') + T.eq(model:roller(),roller,'roller identity is stable') + T.eq(#trace,0,'lookup and cache creation consume no native draws') + for n=1,256 do + local index=#trace+1 + T.eq(probe.love(model,n),1+(index*37)%n,'source LOVE result uses the next actual native draw') + T.eq(#trace,index,'one actual draw for LOVE call') + index=#trace+1 + T.eq(probe.cart(model,n),(index*37)%n,'source random uses zero-based native result') + T.eq(#trace,index,'one actual draw for source random') + index=#trace+1 + T.eq(probe.method(model,n),(index*37)%n,'public class roller uses the next same native draw') + T.eq(#trace,index,'one actual draw for source roller') + end + for _,bounds in ipairs{{5,9},{9,5},{-3,2},{4,4}}do + local lo,hi=math.min(bounds[1],bounds[2]),math.max(bounds[1],bounds[2]) + local index=#trace+1 + T.eq(probe.love(model,bounds[1],bounds[2]),lo+(index*37)%(hi-lo+1),'source range normalization uses native draw') + T.eq(trace[index].lo,lo,'normalized native lower bound') + T.eq(trace[index].hi,hi,'normalized native upper bound') + end + for _,bound in ipairs{0,-7}do + local index=#trace+1 + T.eq(probe.love(model,bound),1,'source small bound normalizes to one') + T.eq(#trace,index,'normalized one-point range still consumes one actual draw') + end + local index=#trace+1 + T.eq(probe.love(model),1,'source missing bound normalizes to one') + T.eq(#trace,index,'missing bound uses one actual native draw') + local replacement=stream('replacement');b.rng=replacement + index=#trace+1 + T.eq(loveRng(2,8),2+(index*37)%7,'retained LOVE view follows replacement owner') + T.eq(trace[index].tag,'replacement','retained LOVE view cannot draw from old function') + index=#trace+1 + T.eq(random(256),(index*37)%256,'retained random follows replacement owner') + T.eq(trace[index].tag,'replacement','retained zero-based view cannot draw from old function') + index=#trace+1 + T.eq(roller(255),(index*37)%255,'retained roller follows replacement owner') + T.eq(trace[index].tag,'replacement','retained roller cannot draw from old function') + T.eq(b.rng,replacement,'adapters never replace the actual native function') + b:switchPlayerInto(Screen.POS.PLAYER_LEFT,save.party[2]) + T.eq(b.battle,model,'real native switch retains its model') + T.eq(model.rng,loveRng,'real native switch retains LOVE adapter') + T.eq(model:roller(),roller,'real native switch retains source roller') + index=#trace+1 + T.eq(probe.method(model,256),(index*37)%256,'incoming actor shares the same real stream') + T.eq(#trace,index,'incoming actor does not create another generator') + T.raises(function()model.rng=function()end end,'read-only','source view cannot replace native RNG authority') + T.raises(function()model.random=function()end end,'read-only','source view cannot replace cart RNG authority') + T.raises(function()model.roller=function()end end,'read-only','source metadata cannot replace the public method') + end + for _,mon in ipairs(save.party)do + T.eq(mon.rng,nil,'no RNG function enters party mon') + T.eq(mon.random,nil,'no random function enters party mon') + T.eq(mon._nativeRoller,nil,'no adapter cache enters party mon') + end + run.release() +end +-- Internal adapter cycles must be collectible when their native owner and +-- caller references end. A global value closure retaining a weak key is not. +local weak=setmetatable({},{__mode='k'}) +local function retired() + local screen={rng=function(n)return n end};local model=Model.bind({},screen) + Model.roller(model);weak[model]=true;weak[screen]=true +end +for _=1,12 do retired()end +collectgarbage('collect');collectgarbage('collect') +T.eq(next(weak),nil,'retired model/RNG adapters do not leak through module state') +V.set(before) +T.finish('native source model RNG fields, domains, replacement, switch and lifetime') From 059b55375c7f922266fce11b740224ce3caf75c9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:07:32 +0100 Subject: [PATCH 147/187] Preserve the distinct source random field and decorated roller seam --- src/battle/Gen2BattleView.lua | 2 +- src/battle/gen2/Battle.lua | 4 +++- tests/engine/gen2_model_rng.lua | 19 ++++++++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua index a5d6fe5bd..9a0e8b49c 100644 --- a/src/battle/Gen2BattleView.lua +++ b/src/battle/Gen2BattleView.lua @@ -23,7 +23,7 @@ function View.bind(screen) save = function() return screen.game.save end, data = function() return screen.data end, rng = function() return rawget(screen.battle, '_nativeLoveRng') end, - random = function() return screen.battle:roller() end, + random = function() return rawget(screen.battle, '_nativeRandom') end, wild = function() return screen.kind == "wild" end, playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index c71bc500f..e33aadf0c 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -12,6 +12,7 @@ end function Model.bind(model, screen) screens[model] = screen + rawset(model, '_nativeRandom', function(n) return owner(model).rng(n) - 1 end) -- Source GSC's LOVE-style view uses the current native function even if -- link setup or a scoped native owner replaces it after this bind. rawset(model, '_nativeLoveRng', function(lo, hi) @@ -29,7 +30,8 @@ function Model:roller() if not roller then -- Zero-based BattleRandom over the same existing stream. Keep the cache -- on the transient model; a global value closure could root a weak key. - roller = function(n) return owner(self).rng(n) - 1 end + local random = rawget(self, '_nativeRandom') + roller = function(n) return random(n) end rawset(self, '_nativeRoller', roller) end return roller diff --git a/tests/engine/gen2_model_rng.lua b/tests/engine/gen2_model_rng.lua index b07ecddda..be92999ce 100644 --- a/tests/engine/gen2_model_rng.lua +++ b/tests/engine/gen2_model_rng.lua @@ -1,4 +1,4 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. -- Actual source consumers must share the native stream, including after its -- function is replaced. No shadow generator, draws on lookup or saved closures. package.path='./?.lua;./?/init.lua;'..package.path @@ -20,6 +20,13 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do mod.exports.love=function(model,lo,hi)return model.rng(lo,hi)end mod.exports.cart=function(model,n)return model.random(n)end mod.exports.method=function(model,n)return Battle.roller(model)(n)end + mod.exports.decorate=function() + local native=Battle.roller + Battle.roller=function(model) + mod.exports.lookups=(mod.exports.lookups or 0)+1 + return native(model) + end + end ]], })}) T.eq(#run.errors,0,'actual API2 source RNG consumer loads') @@ -46,6 +53,7 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do T.eq(model.rng,loveRng,'LOVE adapter identity is stable') T.eq(model.random,random,'zero-based adapter identity is stable') T.eq(model:roller(),roller,'roller identity is stable') + T.check(random~=roller,'source random field remains separate from the roller method wrapper') T.eq(#trace,0,'lookup and cache creation consume no native draws') for n=1,256 do local index=#trace+1 @@ -91,6 +99,15 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do index=#trace+1 T.eq(probe.method(model,256),(index*37)%256,'incoming actor shares the same real stream') T.eq(#trace,index,'incoming actor does not create another generator') + probe.decorate() + local count=#trace + T.eq(model.random,random,'decorating roller does not replace the source random field') + T.eq(model.rng,loveRng,'decorating roller does not replace source LOVE field') + T.eq(probe.lookups,nil,'source field lookup does not invoke a decorated roller method') + index=#trace+1 + T.eq(probe.method(model,255),(index*37)%255,'actual API2 roller decoration keeps native draw ownership') + T.eq(probe.lookups,1,'actual API2 consumer reaches the live class decoration') + T.eq(#trace,count+1,'decoration lookup itself adds no draw') T.raises(function()model.rng=function()end end,'read-only','source view cannot replace native RNG authority') T.raises(function()model.random=function()end end,'read-only','source view cannot replace cart RNG authority') T.raises(function()model.roller=function()end end,'read-only','source metadata cannot replace the public method') From be9b3d074b22ddadb84a2c68e3a06bc17b5bac56 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:09:38 +0100 Subject: [PATCH 148/187] Exercise actual source RNG callbacks in older-host linked roles --- tests/engine/gen2_model_rng_link.lua | 77 ++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/engine/gen2_model_rng_link.lua diff --git a/tests/engine/gen2_model_rng_link.lua b/tests/engine/gen2_model_rng_link.lua new file mode 100644 index 000000000..d3104599c --- /dev/null +++ b/tests/engine/gen2_model_rng_link.lua @@ -0,0 +1,77 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Source callbacks draw from the actual seeded link stream in every role. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Copy=require('src.mods.Merge').deepCopy +local P,N,L=require('src.link.Protocol'),require('src.link.Net'),require('src.link.LinkBattle') +local old=V.get() +for _,edition in ipairs{'gold','silver','crystal'}do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.QA_ENDURE={id='QA_ENDURE',name='QA ENDURE',power=0,type='NORMAL',accuracy=100,pp=20,effect='ENDURE_EFFECT'} + local run=T.sdk.loadMods({'mods/source_rng'},{data=data,fs=T.sdk.memfs({ + ['mods/source_rng/manifest.json']='{"id":"source_rng","name":"Source RNG","version":"1.0.0","api":2,"entry":"main.lua","affects_link":true}', + ['mods/source_rng/main.lua']=[[ + local mod=... + local Model=require('src.battle.gen2.Battle') + mod.exports.rows={} + mod.events:on('battle.move_used',function(c) + local model=c.battle.battle + local rows=mod.exports.rows[c.battle] + if not rows then rows={};mod.exports.rows[c.battle]=rows end + rows[#rows+1]={name=c.user.mon.nickname,love=model.rng(16), + cart=model.random(256),roller=Model.roller(model)(255)} + end) + ]], + })}) + T.eq(#run.errors,0,'actual source callback consumer loads');T.link.prepare(data) + local games={} + for i=1,3 do + local game=T.link.fakeGame(data,'FIXMON_A',{name='ROLE'..i,level=30}) + game.save.version=edition;game.save.party[1].nickname=i==1 and'HOST'or'GUEST' + game.save.party[1].moves={{id='QA_ENDURE',pp=20}};games[i]=game + end + local na,nb=N.loopbackPair();local inbox={} + local sn={update=function()end,poll=function()local packets=inbox;inbox={};return packets end} + for i,net in ipairs{na,nb}do + local send,side=net.send,i==1 and'host'or'guest' + net.send=function(self,packet)inbox[#inbox+1]={type='spectate',side=side,msg=Copy(packet)};return send(self,packet)end + end + local pa,pb=P.packParty(games[1].save.party),P.packParty(games[2].save.party) + local a=assert(L.newHost(games[1],na,{myParty=pa,theirParty=pb,theirName='GUEST',seed=327,verdict='full',strict=true})) + local b=assert(L.newGuest(games[2],nb,{myParty=pb,theirParty=pa,theirName='HOST',seed=327,verdict='full',strict=true})) + local s=assert(L.newSpectator(games[3],sn,{hostParty=pa,guestParty=pb,hostName='HOST',guestName='GUEST',seed=327,verdict='full',strict=true})) + local roles={a,b,s} + for i,battle in ipairs(roles)do games[i].stack:push(battle)end + for _=1,30000 do + for i,game in ipairs(games)do + require('src.core.Input').pressed=(i==3 or not roles[i].localHashes[2])and{a=true}or{} + game.stack:update(1/60) + end + if a.localHashes[2]and b.localHashes[2]and s.turnCount==2 and s.phase=='waitBoth'then break end + end + for turn=1,2 do + T.check(a.localHashes[turn]~=nil and b.localHashes[turn]~=nil,'actual source-consumer link turn completes') + T.eq(a.localHashes[turn],b.localHashes[turn],'source RNG calls preserve native lockstep') + T.same(a.localParts[turn],b.localParts[turn],'every completed native state component agrees') + end + local rows=run.loader.exports.source_rng.rows + T.same(rows[a],rows[b],'both participants observe the same source RNG results') + T.same(rows[a],rows[s],'spectator observes the same source RNG results') + for _,battle in ipairs(roles)do + T.eq(#(rows[battle]or{}),4,'every actual move callback reads all three source RNG domains') + for _,row in ipairs(rows[battle]or{})do + T.check(row.love>=1 and row.love<=16,'source LOVE call retains its positive range') + T.check(row.cart>=0 and row.cart<256,'source random retains its byte range') + T.check(row.roller>=0 and row.roller<255,'source roller retains its nonzero-byte-index range') + end + T.eq(battle.player.mon.moves[1].pp,18,'own native moves spend PP once despite source random calls') + T.eq(battle.enemy.mon.moves[1].pp,18,'other native moves spend PP once despite source random calls') + T.eq(battle.result,nil,'no hidden link simulation error') + end + T.eq(#run.errors,0,'no source callback failure is swallowed') + run.release() +end +V.set(old) +T.finish('actual source RNG callbacks in native GSC link participant and spectator roles') From fdc2be3cd081c04b1467e14d166ccbc2babd51c0 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:12:16 +0100 Subject: [PATCH 149/187] Document older-host source RNG owner and pending independent validation --- docs/native-gen2-model-rng.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/native-gen2-model-rng.md diff --git a/docs/native-gen2-model-rng.md b/docs/native-gen2-model-rng.md new file mode 100644 index 000000000..627668e00 --- /dev/null +++ b/docs/native-gen2-model-rng.md @@ -0,0 +1,24 @@ +# Native Gen2 model RNG + +Expose `rng`, `random` and `roller()` on the live source model, backed by the +existing native controller's current function. LOVE-style ranges normalize +missing/small bounds and reversed ranges; the source random/roller domain is +zero-based for positive integer bounds. Queries consume no randomness and +retained adapters follow native function replacement, including link setup. + +The source random field and decorated roller method stay distinct. The native +read-only reader policy remains in effect. Model-owned function caches do not +enter party mons or serialized battlers, and no second generator is created. +Standalone constructor/RNG-injection and complete source-model parity remain +separate work. + +Reference: read-only Gen1Recomp Gen2 revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, `Battle.new`, its LOVE-style RNG +view and `Battle:roller`. This older host's native model, volatile-state and +protection prerequisites are offered separately. The fork-only semantic +checkpoint backend is absent here, so its checkpoint result is not inherited. + +Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under +the specific PR contribution permission, with existing rights intact. +Validation is pending independent execution on this offered host; no full +model/mod/GPU/release acceptance is claimed from static preparation. From dec137f65b6f48e700a512d47128aae1bd0302c2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:22:04 +0100 Subject: [PATCH 150/187] Record independently tested older-host source RNG and unchanged baseline failures --- docs/native-gen2-model-rng.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/native-gen2-model-rng.md b/docs/native-gen2-model-rng.md index 627668e00..1de093552 100644 --- a/docs/native-gen2-model-rng.md +++ b/docs/native-gen2-model-rng.md @@ -20,5 +20,12 @@ checkpoint backend is absent here, so its checkpoint result is not inherited. Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under the specific PR contribution permission, with existing rights intact. -Validation is pending independent execution on this offered host; no full -model/mod/GPU/release acceptance is claimed from static preparation. +Independent validation on this offered host passes 7951 native RNG checks, +174 actual host/guest/spectator source-callback checks, model (262), volatile +(423) and protection-link (66) regressions. A separately executed frozen Gen1 +comparison passes 9471 comparisons / 7020 draw cases over actual seeded native +LinkBattle backends; reference hashes remain unchanged. The exact official +whole-host command passes 71/82 engine suites and SDK3/3, with the same eleven +pre-existing failures as its protection base (69/80). It adds two passing suites +without adding a whole-host failure; this older prerequisite stack is not +entirely green. Full model/mod/GPU/release acceptance remains separate. From 508b2cf5a1a6c510b0f221d88be4bc471f4d471e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:48:19 +0100 Subject: [PATCH 151/187] Restore native GSC trainer friendship and source class predicates --- docs/native-gen2-trainer-friendship.md | 47 ++++++++++++++++++++++++++ src/battle/BattleState.lua | 10 +++++- src/battle/Gen2TrainerClass.lua | 20 +++++++++++ src/battle/gen2/Battle.lua | 21 ++++++++++++ src/pokemon/Gen2Friendship.lua | 15 ++++++++ 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 docs/native-gen2-trainer-friendship.md create mode 100644 src/battle/Gen2TrainerClass.lua diff --git a/docs/native-gen2-trainer-friendship.md b/docs/native-gen2-trainer-friendship.md new file mode 100644 index 000000000..3ba0b0c66 --- /dev/null +++ b/docs/native-gen2-trainer-friendship.md @@ -0,0 +1,47 @@ +# Native GSC trainer friendship and source class predicates + +The shared native trainer constructor now applies the original Gold/Silver/ +Crystal `GYMBATTLE` friendship event before the first turn. Previously the +award was coupled to `BattleState:enter()` and the narrower badge-leader +marker: the Elite Four, Champion and Red received no award, and construction +alone did not expose the updated values to source consumers. + +`src.battle.gen2.Battle` exposes the source `GYM_LEADER_CLASSES`, +`KANTO_GYM_LEADER_CLASSES`, `isGymLeader(class)` and +`isKantoGymLeader(class)` contracts. The first predicate covers all 22 classes +in the cartridge's combined leader list; the second covers its eight Kanto +classes. The actual native constructor calls the public predicate, so an +API2 class decoration affects the real battle rather than a second model. + +Imported native records retain their numeric group index. The engine maps +that identity to the canonical class identifier rather than comparing the +localized display name, which is often just `LEADER`. It does not mutate the +imported trainer record or claim that every source trainer-view field is +implemented. + +The award uses the existing native friendship owner and the battle's ordered +party view. Living, non-egg members receive +3 below 100, +2 below 200, or +1 +otherwise, clamped to 255. Excluded party members are untouched. API2 +`happiness.changed` callbacks carry the original native mon and the source +event ID 4. Applied events at saturation (and other zero-delta happiness +events) remain observable, as on the reference engine; unchanged walking +steps remain silent. + +The award is not replayed by screen entry, semantic checkpoint reconstruction +or presentation resume. Native badge/music and Yellow companion markers keep +their separate meaning. Other editions and Gen2 hacks keep their existing +friendship rules. No mod, Gen1 installation, player profile, save or ROM is +modified by this change. + +## Validation + +The committed ROM-free regressions exercise real `BattleState.newTrainer`, +the production API2 loader and event bus, all vanilla classes and seven +friendship boundaries, native egg representations, fainted/selected party +members, decorator reachability, other-edition guards and actual semantic +checkpoint reconstruction. Execution results and independent frozen-reference +comparison are recorded in the PR after running those checks. + +This closes the class-predicate/constructor-award gap. It does not establish +complete source-model constructors, turn/event parity, graphical parity, +unchanged VASC acceptance or release readiness. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index d3a3c3a6d..ca51f7dec 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1788,6 +1788,15 @@ function BattleState.newTrainer(game, oppClass, partyIndex) self.trainerLabel = shownName end self.introText = Strings("%s wants\nto fight!", shownName) + -- InitEnemyTrainer awards GSC friendship before the first turn, including + -- Elite Four/Champion/Red. Resolve the public source predicate so API2 + -- decorators affect this actual native owner. The party view contains the + -- original selected mons; excluded save-party members receive no award. + if require("src.pokemon.Gen2Friendship").isVanilla() + and require("src.battle.gen2.Battle").isGymLeader( + require("src.battle.Gen2TrainerClass").fromIndex(self.trainer.index)) then + require("src.pokemon.Gen2Friendship").gymBattle({party=self:playerPartyView()}) + end return self end @@ -3644,7 +3653,6 @@ function BattleState:enter() if self.isGymLeader then require("src.world.PikachuFollower") .modifyHappiness(self.game.save, "GYMLEADER") - require("src.pokemon.Gen2Friendship").gymBattle(self.game.save) end -- normally already playing: the transition wipe starts the theme -- (audio/play_battle_music.asm runs before the transition, and diff --git a/src/battle/Gen2TrainerClass.lua b/src/battle/Gen2TrainerClass.lua new file mode 100644 index 000000000..3a4044175 --- /dev/null +++ b/src/battle/Gen2TrainerClass.lua @@ -0,0 +1,20 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Canonical vanilla GSC class identities in TrainerGroups order. Imported +-- records carry this numeric index; their translated/display name is not an +-- identity (all badge leaders can be named LEADER). +local Class = {} +local names = { + 'FALKNER','WHITNEY','BUGSY','MORTY','PRYCE','JASMINE','CHUCK','CLAIR', + 'RIVAL1','POKEMON_PROF','WILL','CAL','BRUNO','KAREN','KOGA','CHAMPION', + 'BROCK','MISTY','LT_SURGE','SCIENTIST','ERIKA','YOUNGSTER','SCHOOLBOY', + 'BIRD_KEEPER','LASS','JANINE','COOLTRAINERM','COOLTRAINERF','BEAUTY','POKEMANIAC', + 'GRUNTM','GENTLEMAN','SKIER','TEACHER','SABRINA','BUG_CATCHER','FISHER','SWIMMERM', + 'SWIMMERF','SAILOR','SUPER_NERD','RIVAL2','GUITARIST','HIKER','BIKER','BLAINE', + 'BURGLAR','FIREBREATHER','JUGGLER','BLACKBELT_T','EXECUTIVEM','PSYCHIC_T', + 'PICNICKER','CAMPER','EXECUTIVEF','SAGE','MEDIUM','BOARDER','POKEFANM', + 'KIMONO_GIRL','TWINS','POKEFANF','RED','BLUE','OFFICER','GRUNTF','MYSTICALMAN', +} +function Class.fromIndex(index) + return names[tonumber(index)] +end +return Class diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index e33aadf0c..a4a0252d7 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -3,6 +3,27 @@ -- an alias to the screen and does not construct or advance a second battle. local Runtime = require('src.mods.Runtime') local Model = {} +-- IsGymLeader's cartridge list includes the Elite Four, Champion and Red, +-- then falls through into the eight Kanto leaders. Keep this public source +-- predicate distinct from the native badge/Pikachu companion marker. +Model.KANTO_GYM_LEADER_CLASSES = { + BROCK=true, MISTY=true, LT_SURGE=true, ERIKA=true, + JANINE=true, SABRINA=true, BLAINE=true, BLUE=true, +} +Model.GYM_LEADER_CLASSES = { + FALKNER=true, WHITNEY=true, BUGSY=true, MORTY=true, + PRYCE=true, JASMINE=true, CHUCK=true, CLAIR=true, + WILL=true, BRUNO=true, KAREN=true, KOGA=true, CHAMPION=true, RED=true, +} +for class in pairs(Model.KANTO_GYM_LEADER_CLASSES) do + Model.GYM_LEADER_CLASSES[class] = true +end +function Model.isGymLeader(class) + return class ~= nil and Model.GYM_LEADER_CLASSES[class] == true +end +function Model.isKantoGymLeader(class) + return class ~= nil and Model.KANTO_GYM_LEADER_CLASSES[class] == true +end local screens = setmetatable({}, { __mode = 'kv' }) local ended = setmetatable({}, { __mode = 'k' }) diff --git a/src/pokemon/Gen2Friendship.lua b/src/pokemon/Gen2Friendship.lua index 7ef888c15..29dc2ab9b 100644 --- a/src/pokemon/Gen2Friendship.lua +++ b/src/pokemon/Gen2Friendship.lua @@ -11,6 +11,18 @@ local changes = { LEVELUPATHOME = {10,6,4}, } +local events={LEVELUP=1,VITAMIN=2,XITEM=3,GYMBATTLE=4,LEARNMOVE=5, + FAINT=6,POISONFAINT=7,STRONGFOE=8,BITTERPOWDER=15,ENERGYROOT=16,REVIVALHERB=17,LEVELUPATHOME=19} +local function changed(mon,from,event,reason) + local Runtime=require('src.mods.Runtime') + -- Applied source events remain observable even at byte saturation; + -- unchanged walking steps do not report a change. + if (event~=nil or from~=mon.happiness) and Runtime.wants("happiness.changed")then + Runtime.emit("happiness.changed",{mon=mon,event=event,reason=reason, + from=from,to=mon.happiness,delta=mon.happiness-from}) + end +end + function Friendship.isVanilla() return require("src.pokemon.Gen2Breeding").isVanilla() end @@ -23,6 +35,7 @@ function Friendship.change(mon, reason) local h = mon.happiness local band = h < 100 and 1 or h < 200 and 2 or 3 mon.happiness = math.max(0, math.min(255, h + row[band])) + changed(mon,h,events[reason],"event") return true end @@ -34,7 +47,9 @@ function Friendship.stepCycle(save) if save.g2HappinessStepCount ~= 0 then return end for _, mon in ipairs(save.party or {}) do if not require("src.pokemon.Party").isEgg(mon) and mon.happiness ~= nil then + local from=mon.happiness mon.happiness = math.min(255, mon.happiness + 1) + changed(mon,from,nil,"step") end end end From c8e6e959a450327f7326ccf3cd1124a4bafbd747 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:48:38 +0100 Subject: [PATCH 152/187] Test the actual older-host API2 friendship path without checkpoint claims --- docs/native-gen2-trainer-friendship.md | 11 +- tests/engine/gen2_trainer_friendship.lua | 158 +++++++++++++++++++++++ 2 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 tests/engine/gen2_trainer_friendship.lua diff --git a/docs/native-gen2-trainer-friendship.md b/docs/native-gen2-trainer-friendship.md index 3ba0b0c66..05428db6f 100644 --- a/docs/native-gen2-trainer-friendship.md +++ b/docs/native-gen2-trainer-friendship.md @@ -27,8 +27,7 @@ event ID 4. Applied events at saturation (and other zero-delta happiness events) remain observable, as on the reference engine; unchanged walking steps remain silent. -The award is not replayed by screen entry, semantic checkpoint reconstruction -or presentation resume. Native badge/music and Yellow companion markers keep +The award is not replayed by screen entry. Native badge/music and Yellow companion markers keep their separate meaning. Other editions and Gen2 hacks keep their existing friendship rules. No mod, Gen1 installation, player profile, save or ROM is modified by this change. @@ -38,10 +37,14 @@ modified by this change. The committed ROM-free regressions exercise real `BattleState.newTrainer`, the production API2 loader and event bus, all vanilla classes and seven friendship boundaries, native egg representations, fainted/selected party -members, decorator reachability, other-edition guards and actual semantic -checkpoint reconstruction. Execution results and independent frozen-reference +members, decorator reachability and other-edition guards. This older offered +host lacks the fork's semantic checkpoint backend, so the fork-only checkpoint +test/result is not inherited. Execution results and independent frozen-reference comparison are recorded in the PR after running those checks. This closes the class-predicate/constructor-award gap. It does not establish complete source-model constructors, turn/event parity, graphical parity, unchanged VASC acceptance or release readiness. + +Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under +the specific PR permission in LICENSE-ROXAS2712.md; existing rights remain intact. diff --git a/tests/engine/gen2_trainer_friendship.lua b/tests/engine/gen2_trainer_friendship.lua new file mode 100644 index 000000000..7b2133586 --- /dev/null +++ b/tests/engine/gen2_trainer_friendship.lua @@ -0,0 +1,158 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Real native constructors and an API2 consumer, without ROMs or mod edits. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Model=require('src.battle.gen2.Battle') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local old=V.get() +local originalGym,originalKanto=Model.isGymLeader,Model.isKantoGymLeader +-- Independent cartridge class order; display names deliberately all agree. +local classes={'FALKNER','WHITNEY','BUGSY','MORTY','PRYCE','JASMINE','CHUCK','CLAIR', + 'RIVAL1','POKEMON_PROF','WILL','CAL','BRUNO','KAREN','KOGA','CHAMPION', + 'BROCK','MISTY','LT_SURGE','SCIENTIST','ERIKA','YOUNGSTER','SCHOOLBOY', + 'BIRD_KEEPER','LASS','JANINE','COOLTRAINERM','COOLTRAINERF','BEAUTY','POKEMANIAC', + 'GRUNTM','GENTLEMAN','SKIER','TEACHER','SABRINA','BUG_CATCHER','FISHER','SWIMMERM', + 'SWIMMERF','SAILOR','SUPER_NERD','RIVAL2','GUITARIST','HIKER','BIKER','BLAINE', + 'BURGLAR','FIREBREATHER','JUGGLER','BLACKBELT_T','EXECUTIVEM','PSYCHIC_T', + 'PICNICKER','CAMPER','EXECUTIVEF','SAGE','MEDIUM','BOARDER','POKEFANM', + 'KIMONO_GIRL','TWINS','POKEFANF','RED','BLUE','OFFICER','GRUNTF','MYSTICALMAN'} +local leaders={FALKNER=true,WHITNEY=true,BUGSY=true,MORTY=true,PRYCE=true, + JASMINE=true,CHUCK=true,CLAIR=true,WILL=true,BRUNO=true,KAREN=true,KOGA=true, + CHAMPION=true,RED=true,BROCK=true,MISTY=true,LT_SURGE=true,ERIKA=true,JANINE=true, + SABRINA=true,BLAINE=true,BLUE=true} +local kanto={BROCK=true,MISTY=true,LT_SURGE=true,ERIKA=true,JANINE=true, + SABRINA=true,BLAINE=true,BLUE=true} +local function fixture(edition) + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=V.generation(edition) + local run=T.sdk.loadMods({'mods/friendship_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/friendship_probe/manifest.json']='{"id":"friendship_probe","name":"Native friendship probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/friendship_probe/main.lua']=[[ + local mod=... + local Battle=require('src.battle.gen2.Battle') + mod.exports.events={} + mod.events:on('happiness.changed',function(ev) + mod.exports.events[#mod.exports.events+1]=ev + end) + mod.exports.query=function(class) + if type(Battle.isGymLeader)~='function' or type(Battle.isKantoGymLeader)~='function' then return end + return Battle.isGymLeader(class),Battle.isKantoGymLeader(class) + end + mod.exports.decorate=function() + local native=Battle.isGymLeader + if type(native)~='function' then return end + Battle.isGymLeader=function(class) + mod.exports.lastClass=class + if mod.exports.override~=nil then return mod.exports.override end + return native(class) + end + end + ]], + })}) + T.eq(#run.errors,0,'real API2 friendship consumer loads: '..edition) + require('src.render.Font').load(data) + local save=Save.newGame();save.party={} + for i=1,5 do save.party[i]=Pokemon.new(data,'FIXMON_A',15)end + local game={data=data,save=save,stack={top=function()end}} + data.trainers.OWN_FRIENDSHIP={id='OWN_FRIENDSHIP',index=1,name='LEADER', + baseMoney=10,parties={{{level=5,species='FIXMON_C'}}}} + return game,run,run.loader.exports.friendship_probe +end +local function prepare(game,h) + for _,mon in ipairs(game.save.party)do + mon.hp,mon.happiness,mon.isEgg,mon.eggSteps=mon.stats.hp,h,nil,nil + end + game.save.party[3].hp=0 + game.save.party[4].isEgg=true + game.save.party[5].eggSteps=256 +end +for _,edition in ipairs{'gold','silver','crystal'}do + local game,run,probe=fixture(edition) + T.eq(type(Model.isGymLeader),'function','public source class predicate exists') + T.eq(type(Model.isKantoGymLeader),'function','public Kanto predicate exists') + for i,class in ipairs(classes)do + local gym,kgym=probe.query(class) + T.eq(gym,leaders[class]==true,'API2 source predicate '..class) + T.eq(kgym,kanto[class]==true,'API2 Kanto predicate '..class) + for _,h in ipairs{0,99,100,199,200,254,255}do + prepare(game,h);probe.events={} + game.data.trainers.OWN_FRIENDSHIP.index=i + local party=game.save.party + local b=Screen.newTrainer(game,'OWN_FRIENDSHIP',1) + local delta=h<100 and 3 or h<200 and 2 or 1 + local expected=leaders[class] and math.min(255,h+delta)or h + for n=1,5 do + T.eq(party[n].happiness,n<=2 and expected or h, + edition..' constructor award '..class..' '..h..' slot '..n) + T.eq(party[n].hp,n==3 and 0 or party[n].stats.hp,'bonus does not heal') + end + T.eq(b:playerPartyView(),party,'constructor keeps actual native party') + T.eq(#probe.events,leaders[class]and 2 or 0,'one event per standing non-egg, including byte saturation') + for n,ev in ipairs(probe.events)do + T.eq(ev.mon,party[n],'API2 event retains actual native mon') + T.eq(ev.event,4,'cartridge GYMBATTLE event id') + T.eq(ev.reason,'event','source reason') + T.eq(ev.from,h,'event before value') + T.eq(ev.to,expected,'event after value') + T.eq(ev.delta,expected-h,'event clamped delta') + end + if h==100 then + local events=#probe.events + b:enter() + T.eq(party[1].happiness,expected,'real enter does not award a second bonus') + T.eq(#probe.events,events,'real enter does not publish duplicate friendship events') + end + end + end + for _,class in ipairs{'UNKNOWN','falkner','LEADER','',false,1}do + local gym,kgym=probe.query(class) + T.eq(gym,false,'unknown source class does not become a leader') + T.eq(kgym,false,'unknown source class does not become a Kanto leader') + end + local gym,kgym=probe.query(nil) + T.eq(gym,false,'nil source class is not a leader');T.eq(kgym,false,'nil Kanto class is not a leader') + -- The native constructor must call the public source seam with canonical + -- identity, rather than a frozen private boolean or localized display name. + probe.decorate();probe.override=false;prepare(game,100);probe.events={} + game.data.trainers.OWN_FRIENDSHIP.index=63 + Screen.newTrainer(game,'OWN_FRIENDSHIP',1) + T.eq(probe.lastClass,'RED','API2 decorator observes actual native Red class') + T.eq(game.save.party[1].happiness,100,'decorated rejection reaches native constructor') + probe.override=true;prepare(game,100);probe.events={} + game.data.trainers.OWN_FRIENDSHIP.index='22' + local b=Screen.newTrainer(game,'OWN_FRIENDSHIP',1,{playerPartyIndices={2,3,4,5}}) + T.eq(probe.lastClass,'YOUNGSTER','ordinary imported numeric-string class resolves canonically') + T.eq(game.save.party[1].happiness,100,'out-of-scope save party member earns no bonus') + T.eq(game.save.party[2].happiness,102,'selected native standing member earns bonus') + T.eq(#probe.events,1,'selected fainted and both native egg representations stay excluded') + T.eq(probe.events[1]and probe.events[1].mon,game.save.party[2],'scoped event uses original selected mon') + T.eq(b:playerPartyView()[1],game.save.party[2],'ordered scope remains live') + -- Source ChangeHappiness reports applied zero-delta events, while its step + -- loop suppresses unchanged saturated values. Keep the two sites distinct. + prepare(game,255);probe.events={} + local friendship=require('src.pokemon.Gen2Friendship') + friendship.change(game.save.party[1],'XITEM') + T.eq(#probe.events,1,'zero-delta event remains observable by the actual API2 consumer') + T.eq(probe.events[1]and probe.events[1].delta,0,'zero-delta event is explicit') + probe.events={};game.save.g2HappinessStepCount=1 + friendship.stepCycle(game.save) + T.eq(#probe.events,0,'unchanged saturated step does not publish a change') + Model.isGymLeader,Model.isKantoGymLeader=originalGym,originalKanto + run.release() +end +for _,edition in ipairs{'red','blue','yellow','ruby','sapphire','emerald','firered','leafgreen','prism','polishedcrystal'}do + local game,run,probe=fixture(edition) + prepare(game,100) + for _,index in ipairs{1,11,16,63,64}do + game.data.trainers.OWN_FRIENDSHIP.index=index + Screen.newTrainer(game,'OWN_FRIENDSHIP',1) + end + T.eq(game.save.party[1].happiness,100,edition..' keeps its own constructor friendship rules') + T.eq(#probe.events,0,edition..' does not receive vanilla GSC award events') + run.release() +end +V.set(old) +T.finish('native GSC trainer class predicates, constructor friendship, live events and scoped party') From a92fd975eca01d4370e1297011d9a511b3962165 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:51:49 +0100 Subject: [PATCH 153/187] Match independently observed source happiness event names --- docs/native-gen2-trainer-friendship.md | 3 ++- src/pokemon/Gen2Friendship.lua | 7 +++++-- tests/engine/gen2_trainer_friendship.lua | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/native-gen2-trainer-friendship.md b/docs/native-gen2-trainer-friendship.md index 05428db6f..ec6a7e811 100644 --- a/docs/native-gen2-trainer-friendship.md +++ b/docs/native-gen2-trainer-friendship.md @@ -23,7 +23,8 @@ The award uses the existing native friendship owner and the battle's ordered party view. Living, non-egg members receive +3 below 100, +2 below 200, or +1 otherwise, clamped to 255. Excluded party members are untouched. API2 `happiness.changed` callbacks carry the original native mon and the source -event ID 4. Applied events at saturation (and other zero-delta happiness +symbolic event (`GYMBATTLE`); the numeric cartridge index remains available +as `eventId` (4). Applied events at saturation (and other zero-delta happiness events) remain observable, as on the reference engine; unchanged walking steps remain silent. diff --git a/src/pokemon/Gen2Friendship.lua b/src/pokemon/Gen2Friendship.lua index 29dc2ab9b..6d58ab815 100644 --- a/src/pokemon/Gen2Friendship.lua +++ b/src/pokemon/Gen2Friendship.lua @@ -13,12 +13,15 @@ local changes = { local events={LEVELUP=1,VITAMIN=2,XITEM=3,GYMBATTLE=4,LEARNMOVE=5, FAINT=6,POISONFAINT=7,STRONGFOE=8,BITTERPOWDER=15,ENERGYROOT=16,REVIVALHERB=17,LEVELUPATHOME=19} +local sourceEvents={LEVELUP='GAINLEVEL',VITAMIN='USEDITEM',XITEM='USEDXITEM', + FAINT='FAINTED',STRONGFOE='BEATENBYSTRONGFOE',LEVELUPATHOME='GAINLEVELATHOME'} local function changed(mon,from,event,reason) local Runtime=require('src.mods.Runtime') -- Applied source events remain observable even at byte saturation; -- unchanged walking steps do not report a change. if (event~=nil or from~=mon.happiness) and Runtime.wants("happiness.changed")then - Runtime.emit("happiness.changed",{mon=mon,event=event,reason=reason, + Runtime.emit("happiness.changed",{mon=mon,event=event and (sourceEvents[event]or event), + eventId=events[event],reason=reason, from=from,to=mon.happiness,delta=mon.happiness-from}) end end @@ -35,7 +38,7 @@ function Friendship.change(mon, reason) local h = mon.happiness local band = h < 100 and 1 or h < 200 and 2 or 3 mon.happiness = math.max(0, math.min(255, h + row[band])) - changed(mon,h,events[reason],"event") + changed(mon,h,reason,"event") return true end diff --git a/tests/engine/gen2_trainer_friendship.lua b/tests/engine/gen2_trainer_friendship.lua index 7b2133586..fd688edbf 100644 --- a/tests/engine/gen2_trainer_friendship.lua +++ b/tests/engine/gen2_trainer_friendship.lua @@ -93,7 +93,8 @@ for _,edition in ipairs{'gold','silver','crystal'}do T.eq(#probe.events,leaders[class]and 2 or 0,'one event per standing non-egg, including byte saturation') for n,ev in ipairs(probe.events)do T.eq(ev.mon,party[n],'API2 event retains actual native mon') - T.eq(ev.event,4,'cartridge GYMBATTLE event id') + T.eq(ev.event,'GYMBATTLE','source constructor event name') + T.eq(ev.eventId,4,'native cartridge event index remains available') T.eq(ev.reason,'event','source reason') T.eq(ev.from,h,'event before value') T.eq(ev.to,expected,'event after value') From ad949620a4e900da9ba071c1d70adeb5c3d2bd38 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:12:56 +0100 Subject: [PATCH 154/187] Supply older-host native party scope prerequisite for trainer friendship --- docs/native-gen2-trainer-friendship.md | 15 +++ src/battle/BattleState.lua | 42 ++++---- src/battle/Gen3MoveEffects.lua | 4 +- src/battle/Gen5MoveEffects.lua | 8 +- src/battle/Gen5MoveRules.lua | 2 +- src/battle/PlayerPartyScope.lua | 36 +++++++ src/ui/Gen3PartyMenu.lua | 4 +- src/ui/Gen4PartyMenu.lua | 4 +- src/ui/PartyMenu.lua | 2 +- tests/engine/native_trainer_party_scope.lua | 102 ++++++++++++++++++++ 10 files changed, 191 insertions(+), 28 deletions(-) create mode 100644 src/battle/PlayerPartyScope.lua create mode 100644 tests/engine/native_trainer_party_scope.lua diff --git a/docs/native-gen2-trainer-friendship.md b/docs/native-gen2-trainer-friendship.md index ec6a7e811..73d5d7388 100644 --- a/docs/native-gen2-trainer-friendship.md +++ b/docs/native-gen2-trainer-friendship.md @@ -33,6 +33,21 @@ their separate meaning. Other editions and Gen2 hacks keep their existing friendship rules. No mod, Gen1 installation, player profile, save or ROM is modified by this change. +## Older-host party prerequisite + +This offered host did not have the fork's battle-local party selection or +`playerPartyView()`. Supply the original native selection integration here: +validate and retain ordered original mon references in the trainer constructor, +then use the same roster for the lead, doubles, native party menus, benches, +party-wide effects, EXP/EXP.ALL and defeat decisions. Never replace or reorder +`save.party`. Invalid selections fall back to the complete original party. + +The standalone `native_trainer_party_scope` regression drives actual older-host +constructors, generation-appropriate menus, AI, Heal Bell/Beat Up, EXP and defeat +across its supported editions. This prerequisite is tested on this offered host; +the corresponding fork result is not inherited. Execution status is recorded +in the draft PR before qualification. + ## Validation The committed ROM-free regressions exercise real `BattleState.newTrainer`, diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index ca51f7dec..e1c2d39fc 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1702,14 +1702,20 @@ end BattleState.buildTrainerParty = buildTrainerParty +function BattleState:playerPartyView() + return self.playerParty or self.game.save.party or {} +end + function BattleState:trainerLabel_() return self.trainerLabel or (self.trainer and self.trainer.name) or "?" end -function BattleState.newTrainer(game, oppClass, partyIndex) +function BattleState.newTrainer(game, oppClass, partyIndex, opts) local self = newBattle(game) self.kind = "trainer" self.oppClass = oppClass + self.playerParty, self.playerPartyIndices = require("src.battle.PlayerPartyScope").select(game, + type(opts) == "table" and opts.playerPartyIndices or nil) -- wOtherTrainerID: which trainer inside the class. PlayBattleMusic reads -- it to tell the two RIVAL2 fights apart, so keep it on the battle. self.partyIndex = tonumber(partyIndex) or 1 @@ -1737,7 +1743,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex) end self.enemyParty = buildTrainerParty(game, oppClass, partyIndex or 1, partyDef) self.enemyIndex = 1 - local playerMon = Party.firstHealthy(game.save.party) + local playerMon = Party.firstHealthy(self:playerPartyView()) if not playerMon then Logger.warn("trainer battle with no healthy party; skipping") self.dead = true @@ -1853,7 +1859,7 @@ function BattleState:sendOutSecondPair() if self:battlerAt(BattleState.POS.PLAYER_RIGHT) == nil and not self.ally then -- the next healthy one that is not already standing in the left slot local lead = self.player and self.player.mon - local second = Party.firstHealthy(game.save.party, lead) + local second = Party.firstHealthy(self:playerPartyView(), lead) if second then self:placeBattler(BattleState.POS.PLAYER_RIGHT, makeBattler(game.data, second, true, game.save, self)) @@ -2000,7 +2006,7 @@ BattleState.EDITOR_TRAINER_CLASS = "OPP_EDITOR_TRAINER" -- `party` is an array of slots in newTrainer's own shape; `look` is the class -- to borrow a pic, palette and AI from (the NPC's own trainerClass, when it -- named one). -function BattleState.newEditorTrainer(game, party, look, name) +function BattleState.newEditorTrainer(game, party, look, name, opts) local trainers = game.data.trainers if type(party) ~= "table" or #party == 0 then return nil, "that team is empty" end local base = look and trainers[look] or nil @@ -2018,7 +2024,7 @@ function BattleState.newEditorTrainer(game, party, look, name) def.paletteSource, def.aiMods = base.paletteSource, base.aiMods end trainers[BattleState.EDITOR_TRAINER_CLASS] = def - return BattleState.newTrainer(game, BattleState.EDITOR_TRAINER_CLASS, 1) + return BattleState.newTrainer(game, BattleState.EDITOR_TRAINER_CLASS, 1, opts) end -- PLATINUM'S ESCORT WILD BATTLE (BATTLE_TYPE_AI_PARTNER, wild_encounters.c): @@ -4509,7 +4515,7 @@ function BattleState:update(dt) -- loops the party menu until a healthy mon is picked, so B and -- fainted picks land back here and reopen it if acting.mon.hp <= 0 then - if Party.firstHealthy(self.game.save.party) then + if Party.firstHealthy(self:playerPartyView()) then self:openReplacementMenu() end return @@ -5119,7 +5125,7 @@ function BattleState:openPokedudeTarget(step, switchMode) if switchMode and p.scriptTick == 48 then p.index = step.switchTo or 2 end if p.scriptTick > 96 and not p._pokedudePicked then p._pokedudePicked = true - local mon = self.game.save.party[p.index] + local mon = self:playerPartyView()[p.index] p:close() if switchMode then self:resolveSwitch(mon) @@ -8720,7 +8726,7 @@ function BattleState:benchFor(isPlayer, position) if isPlayer and self.ally and position == BattleState.POS.PLAYER_RIGHT then return self.ally.party or {} end - if isPlayer then return self.game.save.party or {} end + if isPlayer then return self:playerPartyView() end if position == BattleState.POS.OPPONENT_RIGHT and self.enemyPartyB then return self.enemyPartyB end @@ -8908,7 +8914,7 @@ function BattleState:awardExp(fallen) -- (RemoveFaintedPlayerMon), so it drops out of the divisor and only -- the surviving participants are counted and paid local participants, alive = 0, {} - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if self.participants and self.participants[mon] then participants = participants + 1 if mon.hp > 0 then table.insert(alive, mon) end @@ -9029,7 +9035,7 @@ function BattleState:awardExp(fallen) local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0 if not expAll then local holders = {} - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if (mon.hp or 0) > 0 and holdsExpShare(mon) then holders[#holders + 1] = mon end @@ -9059,9 +9065,9 @@ function BattleState:awardExp(fallen) -- experience.asm:9-13); each mon gets its own GainedText with the -- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no -- summary line - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if mon.hp > 0 then - ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll") + ctx.applyShare(mon, math.max(1, ctx.participants) * #self:playerPartyView() * 2, "expAll") end end end @@ -9106,7 +9112,7 @@ function BattleState:enemyMonFainted() local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name local style = tostring((self.game.save.options or {}).battleStyle or "shift") :lower() - local partyCount = #self.game.save.party + local partyCount = #self:playerPartyView() -- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the -- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays -- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into @@ -9436,7 +9442,7 @@ function BattleState.isOaksLabStarterRival(self) end function BattleState:playerMonFainted() - local nextMon = Party.firstHealthy(self.game.save.party) + local nextMon = Party.firstHealthy(self:playerPartyView()) -- Being out of useable POKéMON blacks you out even when the battle was -- already decided in our favour. A double faint -- our last mon dying -- to residual damage on the turn it lands the KO -- used to hit the @@ -9524,7 +9530,7 @@ function BattleState:playerMonFainted() local ChoiceBox = require("src.ui.ChoiceBox") return ChoiceBox.new(game, function(yes) if yes then return end -- the menu-phase guard opens the party menu - local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0 + local pSpd = (self:playerPartyView()[1].stats or { speed = 0 }).speed or 0 if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then require("src.core.Sound").play(self.data, "Run") self:say(self:romText("_GotAwayText", "Got away safely!")) @@ -9582,7 +9588,7 @@ function BattleState:openReplacementMenu() -- unasked (PlayerHandleChoosePokemon's arena arm returns the next slot) if self.palace or self.arena then self:act(function() - for _, mon in ipairs(game.save.party or {}) do + for _, mon in ipairs(self:playerPartyView()) do if mon.hp > 0 and not Party.isEgg(mon) and mon ~= (self.player and self.player.mon) then onSwitch(mon) return @@ -10862,7 +10868,7 @@ function BattleState:finish() -- go on, the partner heals in the field), so it stands. if self.result ~= "lose" and not self.demo and not self.pokedudeDemo and not (self.ally and self.result == "win") - and not Party.firstHealthy(self.game.save.party) then + and not Party.firstHealthy(self:playerPartyView()) then Logger.warn("battle finished %s with no healthy party; forcing blackout", tostring(self.result)) self.result = "lose" @@ -12611,7 +12617,7 @@ function BattleState:drawHUDs(slide) for i = 10, 17 do hudTile(0x76, i * 8, 88) end hudTile(0x6F, 72, 88) love.graphics.setColor(1, 1, 1, 1) - self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) + self:drawBallRow(self:playerPartyView(), 88, 80, 8) end local hidePlayer = self.safari or self:demoHidesPlayer() if self.player and not hidePlayer and not self.showPlayerBack diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index 57c4f1e8c..384f1af58 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -256,7 +256,7 @@ P.HEAL_BELL_EFFECT = function(battle, user) user.mon.status = nil user.toxicCounter = nil local party = user.isPlayer and battle.game and battle.game.save - and battle.game.save.party + and (battle.playerParty or battle.game.save.party) for _, mon in ipairs(party or {}) do mon.status = nil end return { Strings("A bell chimed!") } end @@ -1028,7 +1028,7 @@ F.FUTURE_SIGHT_EFFECT = { F.BEAT_UP_EFFECT = { hitCount = function(ctx) local party = ctx.user.isPlayer and ctx.battle.game - and ctx.battle.game.save and ctx.battle.game.save.party + and ctx.battle.game.save and (ctx.battle.playerParty or ctx.battle.game.save.party) if not party then return 1 end local n = 0 for _, mon in ipairs(party) do diff --git a/src/battle/Gen5MoveEffects.lua b/src/battle/Gen5MoveEffects.lua index 64682dade..46abc5276 100644 --- a/src/battle/Gen5MoveEffects.lua +++ b/src/battle/Gen5MoveEffects.lua @@ -1545,7 +1545,7 @@ end }) DEF.BW_BEAT_UP = F({ hitCount = function(ctx) local party = ctx.user.isPlayer and ctx.battle.game and ctx.battle.game.save - and ctx.battle.game.save.party or ctx.battle.enemyParty + and (ctx.battle.playerParty or ctx.battle.game.save.party) or ctx.battle.enemyParty local n = 0 for _, mon in ipairs(party or {}) do if (mon.hp or 0) > 0 and not mon.status then n = n + 1 end @@ -1708,7 +1708,7 @@ end }) local function benchOf(battle, b) local out = {} if b.isPlayer then - local party = battle.game and battle.game.save and battle.game.save.party or {} + local party = battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or {} for _, mon in ipairs(party) do if mon ~= b.mon and (mon.hp or 0) > 0 and not mon.isEgg and not mon.egg then local onField = false @@ -2105,7 +2105,7 @@ DEF.BW_REFRESH = P({ run = enginePrimary("REFRESH_EFFECT") }) DEF.BW_HEAL_BELL = P({ run = function(ctx) local battle, user = ctx.battle, ctx.user user.mon.status, user.toxicCounter = nil, nil - local party = user.isPlayer and battle.game and battle.game.save and battle.game.save.party + local party = user.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty for _, mon in ipairs(party or {}) do mon.status = nil end return { T.std(battle, ctx.move.id == 312 and "aroma" or "bell") } @@ -2490,7 +2490,7 @@ DEF.BW_METRONOME = F({ callsMove = function(ctx) end }) DEF.BW_ASSIST = F({ callsMove = function(ctx) local battle, u = ctx.battle, ctx.user - local party = u.isPlayer and battle.game and battle.game.save and battle.game.save.party or battle.enemyParty + local party = u.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty local pool = {} for _, mon in ipairs(party or {}) do if mon ~= u.mon then diff --git a/src/battle/Gen5MoveRules.lua b/src/battle/Gen5MoveRules.lua index b6faa003d..c8afd08cf 100644 --- a/src/battle/Gen5MoveRules.lua +++ b/src/battle/Gen5MoveRules.lua @@ -609,7 +609,7 @@ end local function beatUp(G5) return F({ perform = function(ctx, rec) local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move - local party = user.isPlayer and battle.game and battle.game.save and battle.game.save.party + local party = user.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty or {} local members = {} for _, mon in ipairs(party) do diff --git a/src/battle/PlayerPartyScope.lua b/src/battle/PlayerPartyScope.lua new file mode 100644 index 000000000..6706a2b4b --- /dev/null +++ b/src/battle/PlayerPartyScope.lua @@ -0,0 +1,36 @@ +-- Battle-local ordered references; never reorder or replace save.party. +local Logger = require("src.core.Logger") +local M = {} +function M.select(game, indices) + if indices == nil then + return nil, nil + end + local function fallback() + Logger.warn("trainer battle party scope is invalid; using full party") + return nil, nil + end + if type(indices) ~= "table" or #indices == 0 then + return fallback() + end + local count = #indices + local keys = 0 + for key in pairs(indices) do + keys = keys + 1 + if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then + return fallback() + end + end + if keys ~= count then + return fallback() + end + local party, normalized, seen = {}, {}, {} + for i = 1, count do + local index = indices[i] + if type(index) ~= "number" or index % 1 ~= 0 or not game.save.party[index] or seen[index] then + return fallback() + end + seen[index], normalized[i], party[i] = true, index, game.save.party[index] + end + return party, normalized +end +return M diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 4f3f78b39..59029684f 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -168,7 +168,9 @@ function Gen3PartyMenu.new(game, opts) return self end -function Gen3PartyMenu:party() return self.game.save.party or {} end +function Gen3PartyMenu:party() + return (self.battle and self.battle.playerParty) or self.game.save.party or {} +end -- slots 1..6, then the CANCEL button as slot #party+1 function Gen3PartyMenu:slots() return #self:party() + 1 end diff --git a/src/ui/Gen4PartyMenu.lua b/src/ui/Gen4PartyMenu.lua index 670908c8c..bbf5f6d0c 100644 --- a/src/ui/Gen4PartyMenu.lua +++ b/src/ui/Gen4PartyMenu.lua @@ -261,7 +261,9 @@ function Gen4PartyMenu:drawAt(key, x, y) return true end -function Gen4PartyMenu:party() return self.game.save.party or {} end +function Gen4PartyMenu:party() + return (self.battle and self.battle.playerParty) or self.game.save.party or {} +end -- Slots 1..#party, then CANCEL. function Gen4PartyMenu:count() return #self:party() + (self.select and 2 or 1) end diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index e59d7173b..8a32551c2 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -438,7 +438,7 @@ function PartyMenu.new(game, opts) self.tmhm = opts.tmhm self.forceSwitch = opts.forceSwitch self.battle = opts.battle - self.party = opts.party -- link battles pass their clamped copies + self.party = opts.party or (opts.battle and opts.battle.playerParty) -- link copies or trainer selection self.swapFrom = nil self.submenu = nil self.subIndex = 1 diff --git a/tests/engine/native_trainer_party_scope.lua b/tests/engine/native_trainer_party_scope.lua new file mode 100644 index 000000000..2fae854b2 --- /dev/null +++ b/tests/engine/native_trainer_party_scope.lua @@ -0,0 +1,102 @@ +-- Copyright (c) 2026 Roxas2712. See the offered contribution licence. +-- Actual older-host constructors, menus, EXP/faint owners and party effects. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local B=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local old=V.get() +for _,edition in ipairs(V.ORDER)do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=V.generation(edition) + local run=T.sdk.loadMods({'mods/scope_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/scope_probe/manifest.json']='{"id":"scope_probe","name":"Native scope probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/scope_probe/main.lua']=[[ + local mod=... + mod.hooks:wrap('battle.exp_award',function(next,ctx) + mod.exports.award=ctx + return next(ctx) + end) + ]], + })}) + T.eq(#run.errors,0,'real native party observer loads: '..edition) + require('src.render.Font').load(data) + local save=Save.newGame();save.party={} + for i=1,3 do save.party[i]=Pokemon.new(data,'FIXMON_A',20+i)end + local game={data=data,save=save,stack={top=function()end}} + data.trainers.OWN_SCOPE={id='OWN_SCOPE',index=22,name='SCOPE',baseMoney=10, + parties={{{level=5,species='FIXMON_C'}}}} + local original,first,second,third=save.party,save.party[1],save.party[2],save.party[3] + local selected={3,2} + local b=B.newTrainer(game,'OWN_SCOPE',1,{playerPartyIndices=selected}) + T.eq(b.player.mon,third,'native lead is the first selected original mon') + T.eq(b:playerPartyView()[1],third,'ordered scope retains actual first reference') + T.eq(b:playerPartyView()[2],second,'ordered scope retains actual second reference') + T.neq(b.playerPartyIndices,selected,'caller cannot mutate retained selection indices') + T.eq(b:benchFor(true),b.playerParty,'native bench uses the actual selected roster') + T.eq(#b:benchFor(true),2,'excluded healthy mon is not a replacement') + local menu=b:buildScreen(require('src.battle.Gen4Battle').partyScreenId(b)or 'PartyMenu', + {battle=b,onSwitch=function()end}) + local shown=type(menu.party)=='function'and menu:party()or menu.party + T.eq(shown,b.playerParty,'actual generation-appropriate party menu uses scope') + T.eq(#shown,2,'actual party menu cannot offer an excluded mon') + -- The existing AI already understands playerParty. Ensure construction + -- and replacement draw from that same original roster, including doubles. + local vm={battle=b,user=b.player,target=b.enemy} + require('src.battle.Gen3AI').OPS.count_usable_party_mons(vm,{1}) + T.eq(vm.result,1,'real AI counts only one selected off-field replacement') + b.double=true;b:sendOutSecondPair() + T.eq(b:battlerAt(B.POS.PLAYER_RIGHT).mon,second,'second flank draws from selected roster') + require('src.battle.Gen3AI').OPS.count_usable_party_mons(vm,{1}) + T.eq(vm.result,0,'both selected mons on field leave no AI replacement') + -- Party-wide effects must not heal or count the excluded save-party mon. + for _,mon in ipairs(save.party)do mon.status='BRN'end + require('src.battle.Gen3MoveEffects').primary.HEAL_BELL_EFFECT(b,b.player) + T.eq(first.status,'BRN','actual Heal Bell leaves excluded mon alone') + T.eq(second.status,nil,'actual Heal Bell heals selected second mon') + T.eq(third.status,nil,'actual Heal Bell heals selected lead') + first.status=nil + local count=require('src.battle.Gen3MoveEffects').full.BEAT_UP_EFFECT.hitCount( + {battle=b,user=b.player,target=b.enemy}) + T.eq(count,2,'real Beat Up hit count excludes healthy save-party outsider') + if V.isGen5()then + for _,mon in ipairs(save.party)do mon.status='BRN'end + require('src.battle.Gen5MoveEffects').record(b,'BW_HEAL_BELL').run({ + battle=b,user=b.player,target=b.player, + move={id='OWN_SCOPE_BELL',name='BELL',power=0,type='NORMAL'}, + }) + T.eq(first.status,'BRN','actual Gen5 effect leaves excluded mon alone') + T.eq(second.status,nil,'actual Gen5 effect heals selected second mon') + T.eq(third.status,nil,'actual Gen5 effect heals selected lead') + end + -- Native EXP.ALL includes exactly this battle's party, even if stale + -- participation bookkeeping still names the excluded save-party mon. + b.queue={};b.participants={[first]=true,[second]=true,[third]=true} + save.inventory.EXP_ALL=1 + local excludedExp=first.exp + b:awardExp(b.enemy) + local award=run.loader.exports.scope_probe.award + T.check(award~=nil,'actual native EXP hook executes') + if award then + T.eq(award.participants,2,'actual EXP divisor contains selected participants') + T.eq(#award.alive,2,'actual EXP living roster excludes outsider') + end + T.eq(first.exp,excludedExp,'actual EXP.ALL cannot pay excluded outsider') + second.hp,third.hp=0,0 + T.check(first.hp>0,'excluded save-party member remains healthy') + b.result=nil;b:playerMonFainted() + T.eq(b.result,'lose','actual native defeat ignores excluded healthy party member') + T.eq(save.party,original,'battle never replaces real save.party') + T.eq(save.party[1],first,'battle never reorders excluded save-party member') + T.eq(save.party[2],second,'battle never reorders selected save-party member') + T.eq(save.party[3],third,'battle retains every original party mon') + for _,invalid in ipairs{{},{1,1},{0},{4},{1.5},{[1]=1,[3]=2},{1,unexpected=true},'bad'}do + local party,indices=require('src.battle.PlayerPartyScope').select(game,invalid) + T.eq(party,nil,'invalid native selection falls back to complete party') + T.eq(indices,nil,'invalid selection keeps no misleading remap') + end + run.release() +end +V.set(old) +T.finish('older-host real trainer party selection, UI, AI, party effects, EXP and defeat') From 607a0e772c1dfffa68c42b60ef67949549e97886 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:15:00 +0100 Subject: [PATCH 155/187] Clarify separate older-host scope qualification requirement --- docs/native-gen2-trainer-friendship.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/native-gen2-trainer-friendship.md b/docs/native-gen2-trainer-friendship.md index 73d5d7388..b21b759eb 100644 --- a/docs/native-gen2-trainer-friendship.md +++ b/docs/native-gen2-trainer-friendship.md @@ -44,9 +44,9 @@ party-wide effects, EXP/EXP.ALL and defeat decisions. Never replace or reorder The standalone `native_trainer_party_scope` regression drives actual older-host constructors, generation-appropriate menus, AI, Heal Bell/Beat Up, EXP and defeat -across its supported editions. This prerequisite is tested on this offered host; -the corresponding fork result is not inherited. Execution status is recorded -in the draft PR before qualification. +across its supported editions. Qualification requires execution on this offered +host; the corresponding fork result is not inherited. Execution status is +recorded in the draft PR. ## Validation From b17a8f1b955a3ecaa44ac814bc391bbece41fff3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:31:33 +0100 Subject: [PATCH 156/187] Use actual entered battle phase and native species schema in scope regression --- tests/engine/native_trainer_party_scope.lua | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/engine/native_trainer_party_scope.lua b/tests/engine/native_trainer_party_scope.lua index 2fae854b2..ee05106f1 100644 --- a/tests/engine/native_trainer_party_scope.lua +++ b/tests/engine/native_trainer_party_scope.lua @@ -10,6 +10,17 @@ local old=V.get() for _,edition in ipairs(V.ORDER)do V.set(edition) local data=T.fixtures.fresh();data.constants.gen=V.generation(edition) + if data.constants.gen>=3 then + -- These editions use the structural six-stat/IV schema. The base fixture + -- is Gen1-shaped, so give the owned species their proper native layout. + for _,def in pairs(data.pokemon)do + if type(def)=='table'and def.baseStats then + def.baseStats.spatk=def.baseStats.spatk or def.baseStats.special + def.baseStats.spdef=def.baseStats.spdef or def.baseStats.special + def.evYield={hp=0,attack=0,defense=0,speed=0,spatk=0,spdef=0} + end + end + end local run=T.sdk.loadMods({'mods/scope_probe'},{data=data,fs=T.sdk.memfs({ ['mods/scope_probe/manifest.json']='{"id":"scope_probe","name":"Native scope probe","version":"1.0.0","api":2,"entry":"main.lua"}', ['mods/scope_probe/main.lua']=[[ @@ -36,6 +47,10 @@ for _,edition in ipairs(V.ORDER)do T.neq(b.playerPartyIndices,selected,'caller cannot mutate retained selection indices') T.eq(b:benchFor(true),b.playerParty,'native bench uses the actual selected roster') T.eq(#b:benchFor(true),2,'excluded healthy mon is not a replacement') + -- AI runs after real screen entry initializes native on-field sides; a + -- constructor's not-yet-entered empty side table is not an AI phase. + b:enter() + T.eq(b.sides[1].battlers[1],b.player,'actual enter initializes the selected on-field owner') local menu=b:buildScreen(require('src.battle.Gen4Battle').partyScreenId(b)or 'PartyMenu', {battle=b,onSwitch=function()end}) local shown=type(menu.party)=='function'and menu:party()or menu.party From 8d781f27d921ccb46b40ef2e2fa139e34b4bf74a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:40:09 +0100 Subject: [PATCH 157/187] Test source round clock at actual native GSC execution boundaries --- tests/engine/gen2_model_round_clock.lua | 77 +++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/engine/gen2_model_round_clock.lua diff --git a/tests/engine/gen2_model_round_clock.lua b/tests/engine/gen2_model_round_clock.lua new file mode 100644 index 000000000..070927229 --- /dev/null +++ b/tests/engine/gen2_model_round_clock.lua @@ -0,0 +1,77 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- An actual API2 consumer observes the native round clock at execution edges. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local old=V.get() +for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.QA_CLOCK={id='QA_CLOCK',name='CLOCK',power=0,type='NORMAL',accuracy=100,pp=20,effect='ENDURE_EFFECT'} + local run=T.sdk.loadMods({'mods/round_clock'},{data=data,fs=T.sdk.memfs({ + ['mods/round_clock/manifest.json']='{"id":"round_clock","name":"Round clock","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/round_clock/main.lua']=[[ + local mod=... + mod.exports.rows={} + for _,name in ipairs{'battle.turn_started','battle.move_used','battle.turn_ended'}do + local eventName=name + mod.events:on(name,function(event) + local b=event.battle + mod.exports.rows[#mod.exports.rows+1]={name=eventName,turn=b.battle.turn,native=b.turnCount,eventTurn=event.turn} + end) + end + mod.exports.read=function(model)return model.turn end + ]], + })}) + T.eq(#run.errors,0,'real API2 round observer loads') + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',30)} + save.party[1].moves={{id='QA_CLOCK',pp=20}} + local b=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30) + b.enemy.curMoves={{id='QA_CLOCK',pp=20}} + b.enemyAction=function()return b.enemy.curMoves[1]end + local probe=run.loader.exports.round_clock + local model=b.battle + T.eq(probe.read(model),0,edition..' starts at source round zero') + T.eq(b.turnCount,nil,'reading initial round does not initialize native state') + for round=1,3 do + local pp=b.player.mon.moves[1].pp + b.queue={};b:resolveTurn(b.player.curMoves[1]) + T.eq(probe.read(model),round,'actual queued native round reaches public model') + local queued=#b.queue + for _=1,10 do T.eq(probe.read(model),round,'repeated observations never advance a round')end + T.eq(#b.queue,queued,'clock observation cannot drain native actions') + T.eq(b.player.mon.moves[1].pp,pp,'clock observation cannot spend PP') + -- Run the actual two chosen moves; their callback reads must see the same + -- round as the earlier turn-start callback, not a per-move/frame counter. + b:runQueueAction(b.queue[1]);b:runQueueAction(b.queue[2]) + T.eq(b.player.mon.moves[1].pp,pp-1,'actual native move spends PP once') + T.eq(probe.read(model),round,'both actual moves share one round') + b:endTurn() + T.eq(probe.read(model),round,'residual processing does not create a new round') + end + local starts,moves,ends=0,0,0 + for _,row in ipairs(probe.rows)do + T.eq(row.turn,row.native,'API2 execution event sees actual native round') + if row.eventTurn then T.eq(row.turn,row.eventTurn,'public model agrees with native event round')end + if row.name=='battle.turn_started'then starts=starts+1 + elseif row.name=='battle.move_used'then moves=moves+1 + elseif row.name=='battle.turn_ended'then ends=ends+1 end + end + T.eq(starts,3,'one real start callback per chosen round') + T.eq(moves,6,'both sides execute in every real round') + T.eq(ends,3,'one real residual completion callback per round') + T.eq(save.party[1].turn,nil,'round state never enters a party Pokemon') + T.eq(save.turn,nil,'round state never enters persistent save data') + local incoming=save.party[2] + b:switchPlayerInto(Screen.POS.PLAYER_LEFT,incoming) + T.eq(model.player,incoming,'native switch really replaces the player') + T.eq(probe.read(model),3,'native switch does not reset battle-wide clock') + T.eq(#run.errors,0,'no swallowed source callback error') + run.release() +end +V.set(old) +T.finish('native GSC source model round clock at actual queue and API2 event edges') From 927bf733665242f8b34fa6a7b72f875631b8e5d4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:41:31 +0100 Subject: [PATCH 158/187] Exercise actual native residual method in round-clock probe --- tests/engine/gen2_model_round_clock.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/engine/gen2_model_round_clock.lua b/tests/engine/gen2_model_round_clock.lua index 070927229..b6de27aa5 100644 --- a/tests/engine/gen2_model_round_clock.lua +++ b/tests/engine/gen2_model_round_clock.lua @@ -50,7 +50,7 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do b:runQueueAction(b.queue[1]);b:runQueueAction(b.queue[2]) T.eq(b.player.mon.moves[1].pp,pp-1,'actual native move spends PP once') T.eq(probe.read(model),round,'both actual moves share one round') - b:endTurn() + b:endOfTurn() T.eq(probe.read(model),round,'residual processing does not create a new round') end local starts,moves,ends=0,0,0 From bc9d9ef77fab5b3d44530c65a27c98a6f332c829 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:43:33 +0100 Subject: [PATCH 159/187] Retain actual queued action rows while native execution inserts text --- tests/engine/gen2_model_round_clock.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/engine/gen2_model_round_clock.lua b/tests/engine/gen2_model_round_clock.lua index b6de27aa5..15c0d7567 100644 --- a/tests/engine/gen2_model_round_clock.lua +++ b/tests/engine/gen2_model_round_clock.lua @@ -47,7 +47,9 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do T.eq(b.player.mon.moves[1].pp,pp,'clock observation cannot spend PP') -- Run the actual two chosen moves; their callback reads must see the same -- round as the earlier turn-start callback, not a per-move/frame counter. - b:runQueueAction(b.queue[1]);b:runQueueAction(b.queue[2]) + local first,second=b.queue[1],b.queue[2] + T.check(first and second and first.fn and second.fn,'native planner queues both actual actions') + b:runQueueAction(first);b:runQueueAction(second) T.eq(b.player.mon.moves[1].pp,pp-1,'actual native move spends PP once') T.eq(probe.read(model),round,'both actual moves share one round') b:endOfTurn() From a1edb86436fcdd07b88726d6ca50ee8d30b74901 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:44:30 +0100 Subject: [PATCH 160/187] Expose the actual native GSC round clock to source model consumers --- src/battle/Gen2BattleView.lua | 4 ++++ tests/engine/gen2_model_rng_link.lua | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua index 9a0e8b49c..787584ccb 100644 --- a/src/battle/Gen2BattleView.lua +++ b/src/battle/Gen2BattleView.lua @@ -29,6 +29,10 @@ function View.bind(screen) enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, over = function() return screen.result ~= nil or screen.dead == true or screen._gen2Complete == true end, outcome = function() return screen.result end, + -- The controller increments once per actual round, before turn-start + -- observers, including link participants and spectators. A source query + -- must not start a round or initialize checkpoint/persistent state. + turn = function() return screen.turnCount or 0 end, battleType = function() return screen.battleType end, trainer = function() return screen.trainer end, tutorial = function() return screen.demo or screen.dudeDemo or screen.pokedudeDemo end, diff --git a/tests/engine/gen2_model_rng_link.lua b/tests/engine/gen2_model_rng_link.lua index d3104599c..5b0194b4c 100644 --- a/tests/engine/gen2_model_rng_link.lua +++ b/tests/engine/gen2_model_rng_link.lua @@ -20,7 +20,7 @@ for _,edition in ipairs{'gold','silver','crystal'}do local model=c.battle.battle local rows=mod.exports.rows[c.battle] if not rows then rows={};mod.exports.rows[c.battle]=rows end - rows[#rows+1]={name=c.user.mon.nickname,love=model.rng(16), + rows[#rows+1]={name=c.user.mon.nickname,turn=model.turn,love=model.rng(16), cart=model.random(256),roller=Model.roller(model)(255)} end) ]], @@ -61,7 +61,8 @@ for _,edition in ipairs{'gold','silver','crystal'}do T.same(rows[a],rows[s],'spectator observes the same source RNG results') for _,battle in ipairs(roles)do T.eq(#(rows[battle]or{}),4,'every actual move callback reads all three source RNG domains') - for _,row in ipairs(rows[battle]or{})do + for index,row in ipairs(rows[battle]or{})do + T.eq(row.turn,math.floor((index-1)/2)+1,'actual participant/spectator moves observe the same native round') T.check(row.love>=1 and row.love<=16,'source LOVE call retains its positive range') T.check(row.cart>=0 and row.cart<256,'source random retains its byte range') T.check(row.roller>=0 and row.roller<255,'source roller retains its nonzero-byte-index range') From fc1aa04068c0a782078d0808a15f1f6170d6f84f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:46:39 +0100 Subject: [PATCH 161/187] Document native round-clock proof and remaining full-model scope --- docs/native-gen2-round-clock.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/native-gen2-round-clock.md diff --git a/docs/native-gen2-round-clock.md b/docs/native-gen2-round-clock.md new file mode 100644 index 000000000..faeda7aae --- /dev/null +++ b/docs/native-gen2-round-clock.md @@ -0,0 +1,32 @@ +# Native Gen2 model round clock + +An API2 reader of `screen.battle.turn` previously received `nil` even while +the native controller emitted numbered turn events. The frozen Gen1 Gen2 +model starts its `turn` field at zero and increments at round admission +(`src/battle/gen2/Battle.lua`, reference revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`). + +The source model now reads the existing native `turnCount`, or zero before +its first round. There is no second counter, timer, event or random stream. +The native controller still owns round admission. Repeated reads, individual +moves, residuals and switches do not advance or reset that clock. Link hosts, +guests and spectators read their actual lockstep controller. Semantic +checkpoint reconstruction reads its restored native count. + +Validation uses actual native constructors, queued actions, API2 event +callbacks, lockstep participants/spectator and semantic checkpoint restore. +The corrected queue probe on the preceding engine reports 295 failures in +400 checks: the public clock is absent at initialization and actual execution +edges. With the engine reader, all 400 pass across Gold, Silver, Crystal, +Prism and Polished Crystal. The link probe passes 210 checks, checkpoint +probe 57, unchanged RNG probe 7951, calculation/completion probe 252 and +friendship checkpoint probe 30 on the fork. The earlier queue-probe drafts +had test-harness mistakes; only its corrected negative run supports the +295-failure comparison. + +This closes the missing live clock observation. It does not implement the +source standalone constructor/turn executor, arbitrary writes to source +model fields, every battle event, full mod visual parity or release-package +acceptance. Those remain separate parity requirements. No mod or original +Gen1/player installation is changed. Implementation and tests are original +contributions by Roxas2712 under `LICENSE-ROXAS2712.md`. From 3e85ef0bd1da912904aa5e5ef6735babafdff641 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:00:11 +0100 Subject: [PATCH 162/187] Exercise older-host actual queue rows without unavailable newer helper --- tests/engine/gen2_model_round_clock.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/engine/gen2_model_round_clock.lua b/tests/engine/gen2_model_round_clock.lua index 15c0d7567..fb1b7055c 100644 --- a/tests/engine/gen2_model_round_clock.lua +++ b/tests/engine/gen2_model_round_clock.lua @@ -49,7 +49,13 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do -- round as the earlier turn-start callback, not a per-move/frame counter. local first,second=b.queue[1],b.queue[2] T.check(first and second and first.fn and second.fn,'native planner queues both actual actions') - b:runQueueAction(first);b:runQueueAction(second) + -- This older host executes action rows inline in updateQueue; the newer + -- runQueueAction helper belongs to a separately offered prerequisite. + -- Execute the actual planner-owned closures with that host's insertion + -- context, rather than adding a fake helper or a second move executor. + for _,item in ipairs{first,second}do + b.nextInsert=0;item.fn();b.nextInsert=nil;b.current=nil + end T.eq(b.player.mon.moves[1].pp,pp-1,'actual native move spends PP once') T.eq(probe.read(model),round,'both actual moves share one round') b:endOfTurn() From f7d6c534313af6fae653911f70a2e5ad090939fa Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:26:57 +0100 Subject: [PATCH 163/187] Separate native battler data from presentation on upstream host --- src/battle/BattleState.lua | 93 ++++++++++---------------------------- src/battle/Battler.lua | 51 +++++++++++++++++++++ 2 files changed, 75 insertions(+), 69 deletions(-) create mode 100644 src/battle/Battler.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e1c2d39fc..2a15aeb0c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -40,6 +40,7 @@ local Gen4Battle = require("src.battle.Gen4Battle") local GameVersion = require("src.core.GameVersion") local Gen2BattleView = require('src.battle.Gen2BattleView') local Gen2Read = require('src.battle.Gen2BattleRead') +local Battler = require('src.battle.Battler') -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -984,75 +985,29 @@ end -- --------------------------------------------------------------------- local function makeBattler(data, mon, isPlayer, save, battle) - local def = require('src.pokemon.Gen4Forms').definition(data,mon) - local badgeBoosts = data.constants and data.constants.badgeBoosts - local badges = nil - if isPlayer and save then - -- Gen 1 badge stat boosts (x9/8); the badge set follows the merged - -- badgeBoosts rows so a retuned list changes what gets baked in - badges = {} - for _, row in ipairs(badgeBoosts or Damage.BADGE_BOOSTS) do - if save.inventory[row.badge] then badges[row.badge] = true end - end - end - local battler = { - mon = mon, - def = def, - name = mon.nickname or def.name, - -- The species this battler is SHOWING, which is not always `mon.species`: - -- a Transformed mon wears the copied species' pic and a Tower ghost wears - -- none at all. Both of those move this field where they move `sprite`, - -- so anything drawing the battler -- the flat pic here, or a renderer - -- standing a model in its place -- reads one answer and agrees with the - -- other. `mon.species` stays the truth about the Pokemon itself; this is - -- the truth about the picture. - species = mon.species, - isPlayer = isPlayer, - badges = badges, - -- merged registry views consumed by the pure battle modules - badgeBoosts = badgeBoosts, - statuses = data.statuses, - -- ...and the two HoldItems needs: the item table to look `mon.item` up - -- in, and the nature table, because whether a FIGY BERRY confuses the - -- Pokemon that ate it depends on which stat its nature lowers. Views, - -- not copies, so a berry eaten mid-battle is seen the next time it is - -- asked for. - items = data.items, - natures = data.constants and data.constants.natures, - shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain) - -- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move - -- while the tilemap still shows the prior condition until the next - -- post-action HUD refresh (core.asm after Execute*Move) - shownStatus = mon.status, - -- a sleep put on outside battle with its own count (the Battle Pike's - -- Kirlia: STATUS1_SLEEP, all three bits); spent, it is gone - sleepTurns = (mon.status == "SLP" and mon.sleepTurns) or nil, - stages = {}, - -- volatile state; Transform/Conversion/Mimic override the cur* fields - curStats = mon.stats, - curTypes = def.types, - curMoves = mon.moves, - sprite = (function() - local Sprites = require("src.pokemon.Sprites") - local path, tc = Sprites.path(data, mon.species, - isPlayer and "back" or "front", - { mon = mon, kind = "battle" }) - return getImage(path, monPalette(data, mon.species, - require("src.pokemon.Pokemon").isShiny(mon)), tc) - end)(), - -- Crystal's front-pic frame animation. Only the FRONT pic carries - -- animation tiles, so the player's back pic has none -- and Gold and - -- Silver have none at all, where this stays nil and the pic holds still. - -- forMon rather than new: a shiny Gen 3 Pokemon animates from its own - -- strip, and only the Pokemon can say whether it is one - picAnim = (not isPlayer or (data.pokemon[mon.species] or {}).gen5Animation) - and require("src.pokemon.PicAnim").forMon(data, mon,isPlayer and "back" or "front") or nil, - -- Emerald's procedural front-pic animation -- the squash/hop/glow the - -- sprite itself does when it appears. Front pic only, same as above. - monAnim = (not isPlayer) - and require("src.pokemon.MonAnim").new(data, mon.species) or nil, - } - return Gen2Read.bind(battler, battle) + local battler = Battler.new(data, mon, isPlayer, save, battle) + -- The regular native screen hydrates the same art as before. Pure model + -- construction can use the data factory without allocating GPU resources. + battler.sprite = (function() + local Sprites = require("src.pokemon.Sprites") + local path, tc = Sprites.path(data, mon.species, + isPlayer and "back" or "front", + { mon = mon, kind = "battle" }) + return getImage(path, monPalette(data, mon.species, + require("src.pokemon.Pokemon").isShiny(mon)), tc) + end)() + -- Crystal's front-pic frame animation. Only the FRONT pic carries + -- animation tiles, so the player's back pic has none -- and Gold and + -- Silver have none at all, where this stays nil and the pic holds still. + -- forMon rather than new: a shiny Gen 3 Pokemon animates from its own + -- strip, and only the Pokemon can say whether it is one. + battler.picAnim = (not isPlayer or (data.pokemon[mon.species] or {}).gen5Animation) + and require("src.pokemon.PicAnim").forMon(data, mon, isPlayer and "back" or "front") or nil + -- Emerald's procedural front-pic animation -- the squash/hop/glow the + -- sprite itself does when it appears. Front pic only, same as above. + battler.monAnim = (not isPlayer) + and require("src.pokemon.MonAnim").new(data, mon.species) or nil + return battler end -- LinkBattle builds clamped copies with save=nil (no badge boosts); wild diff --git a/src/battle/Battler.lua b/src/battle/Battler.lua new file mode 100644 index 000000000..4fc8e58cd --- /dev/null +++ b/src/battle/Battler.lua @@ -0,0 +1,51 @@ +-- Native battler data, extracted from BattleState.makeBattler. +-- Existing engine authors' rights and licences remain in effect (see the upstream licences). +-- Data/presentation boundary contributions by Roxas2712; see +-- https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- This module allocates no picture or animation resource. +local Forms = require('src.pokemon.Gen4Forms') +local Gen2 = require('src.battle.Gen2BattleRead') +local Battler = {} + +function Battler.new(data, mon, isPlayer, save, battle) + local def = Forms.definition(data, mon) + local badgeBoosts = data.constants and data.constants.badgeBoosts + local badges + if isPlayer and save then + -- Gen 1 badge stat boosts (x9/8); the badge set follows the merged + -- badgeBoosts rows so a retuned list changes what gets baked in. + badges = {} + for _, row in ipairs(badgeBoosts or require('src.battle.Damage').BADGE_BOOSTS) do + if save.inventory[row.badge] then badges[row.badge] = true end + end + end + local battler = { + mon = mon, + def = def, + name = mon.nickname or def.name, + -- Presentation species may later change for Transform or a Tower ghost; + -- mon.species remains the Pokemon's identity in the save. + species = mon.species, + isPlayer = isPlayer, + badges = badges, + badgeBoosts = badgeBoosts, + statuses = data.statuses, + -- Registry views, not copies: a consumed berry and its nature lookup + -- must see the live merged records throughout the battle. + items = data.items, + natures = data.constants and data.constants.natures, + shownHP = mon.hp, + -- HUDs catch up at native refresh edges, not during a move's mutation. + shownStatus = mon.status, + -- A sleep applied outside battle keeps its own remaining count. + sleepTurns = (mon.status == 'SLP' and mon.sleepTurns) or nil, + stages = {}, + -- Transform/Conversion/Mimic replace these transient views. + curStats = mon.stats, + curTypes = def.types, + curMoves = mon.moves, + } + return Gen2.bind(battler, battle) +end + +return Battler From 281e51852be0b683ae5f52086f6e673f739018b6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:27:05 +0100 Subject: [PATCH 164/187] Exercise plain-data and actual native battler construction --- tests/engine/battler_data.lua | 92 ++++++++++++++++++++ tests/engine/battler_native_construction.lua | 82 +++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 tests/engine/battler_data.lua create mode 100644 tests/engine/battler_native_construction.lua diff --git a/tests/engine/battler_data.lua b/tests/engine/battler_data.lua new file mode 100644 index 000000000..03b7ef5c9 --- /dev/null +++ b/tests/engine/battler_data.lua @@ -0,0 +1,92 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- This process deliberately has no LÖVE stub. Model construction must not +-- silently allocate pictures, animations or a battle screen. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.harness') +T.eq(_G.love, nil, 'fresh plain-Lua process has no rendering runtime') +for _, name in ipairs({ + 'src.battle.BattleState', 'src.pokemon.Sprites', 'src.pokemon.PicAnim', + 'src.pokemon.MonAnim', 'src.render.Assets', +}) do + package.preload[name] = function() error('data constructor loaded presentation: ' .. name) end +end +local Battler = require('src.battle.Battler') +local Version = require('src.core.GameVersion') +local initial = Version.get() +local function copy(t) + if type(t) ~= 'table' then return t end + local r = {}; for k, v in pairs(t) do r[k] = copy(v) end; return r +end + +for _, edition in ipairs(Version.ORDER) do + Version.set(edition) + local label = edition .. ': ' + local types, moves = {'NORMAL'}, {{id='QA_MOVE', pp=8}} + local stats = {hp=40, attack=20, defense=21, speed=22, special=23, spatk=24, spdef=25} + local def = {name='SPECIES', types=types} + local data = {pokemon={QA_MON=def}, statuses={PSN={}}, items={QA_BERRY={}}, + constants={gen=Version.generation(), natures={}, badgeBoosts={ + {badge='QA_BADGE', stat='speed', num=2, den=1}, + {badge='QA_MISSING', stat='attack', num=2, den=1}, + }}} + local mon = {species='QA_MON', nickname='NICK', hp=17, status='PSN', toxic=true, + stats=stats, moves=moves, item='QA_BERRY'} + local save = {inventory={QA_BADGE=1, BOULDERBADGE=1}, party={mon}} + local beforeMon, beforeSave, beforeData = copy(mon), copy(save), copy(data) + local player = Battler.new(data, mon, true, save) + local opponent = Battler.new(data, mon, false, save) + local link = Battler.new(data, mon, true, nil) + T.eq(player.mon, mon, label .. 'original party member is retained') + T.eq(player.def, def, label .. 'species registry row is retained') + T.eq(player.name, 'NICK', label .. 'nickname is displayed') + T.eq(player.curStats, stats, label .. 'stats are a live native view') + T.eq(player.curTypes, types, label .. 'types are a live registry view') + T.eq(player.curMoves, moves, label .. 'PP-bearing move slots retain identity') + T.eq(player.items, data.items, label .. 'item registry retains identity') + T.eq(player.statuses, data.statuses, label .. 'status registry retains identity') + T.eq(player.natures, data.constants.natures, label .. 'nature registry retains identity') + T.eq(player.badgeBoosts, data.constants.badgeBoosts, label .. 'merged badge rows retain identity') + T.same(player.badges, {QA_BADGE=true}, label .. 'only merged possessed badges are captured') + T.eq(opponent.badges, nil, label .. 'opponent does not inherit player badges') + T.eq(link.badges, nil, label .. 'link construction without save has no badge boosts') + T.neq(player.stages, opponent.stages, label .. 'each battler owns transient stages') + T.eq(player.shownHP, 17, label .. 'HUD starts at actual HP') + T.eq(player.shownStatus, 'PSN', label .. 'HUD starts at actual condition') + T.eq(player.toxicCounter, nil, label .. 'older host retains native poison defaults') + for _, battler in ipairs({player, opponent, link}) do + for _, key in ipairs({'sprite', 'picAnim', 'monAnim'}) do + T.eq(battler[key], nil, label .. 'data construction has no ' .. key) + end + end + T.same(mon, beforeMon, label .. 'construction cannot change a Pokemon') + T.same(save, beforeSave, label .. 'construction cannot change a save') + T.same(data, beforeData, label .. 'construction cannot change registries') + mon.hp, mon.status = 3, 'SLP'; mon.sleepTurns = 4; mon.nickname = nil + T.eq(player.shownHP, 17, label .. 'native HUD snapshot waits for refresh') + T.eq(player.shownStatus, 'PSN', label .. 'native condition snapshot waits for refresh') + moves[1].pp = 7; T.eq(player.curMoves[1].pp, 7, label .. 'real PP writes remain visible') + local asleep = Battler.new(data, mon, true, save) + T.eq(asleep.name, def.name, label .. 'unnamed Pokemon uses species name') + T.eq(asleep.sleepTurns, 4, label .. 'outside-battle sleep count survives') + T.eq(asleep.toxicCounter, nil, label .. 'sleep does not acquire a toxic counter') + T.neq(asleep.badges, player.badges, label .. 'badge snapshots are per battler') + data.constants.badgeBoosts = nil + local fallback = Battler.new(data, mon, true, save) + T.same(fallback.badges, {BOULDERBADGE=true}, label .. 'unmerged badges use actual native Damage defaults') + T.eq(_G.love, nil, label .. 'construction never installs a fake rendering runtime') +end + +Version.set('platinum') +local base = {name='DEOXYS', types={'PSYCHIC'}, baseStats={attack=1}} +local attack = {types={'FIGHTING'}, baseStats={attack=180}} +local data = {constants={gen=4}, pokemon={[386]=base, [496]=attack}} +local mon = {species=386, form=1, hp=1, stats={}, moves={}} +local form = Battler.new(data, mon, false) +T.eq(form.species, 386, 'form construction keeps actual species identity') +T.eq(form.name, 'DEOXYS', 'form construction keeps base display name') +T.eq(form.curTypes, attack.types, 'form construction uses native personal-record types') +T.eq(form.def.baseStats, attack.baseStats, 'form construction uses native personal-record stats') +T.eq(data.pokemon[386], base, 'form view does not replace registry row') +T.same(base.types, {'PSYCHIC'}, 'alternate form does not mutate base types') +Version.set(initial) +T.finish('native battler data without LÖVE or presentation resources') diff --git a/tests/engine/battler_native_construction.lua b/tests/engine/battler_native_construction.lua new file mode 100644 index 000000000..cdc21a7b0 --- /dev/null +++ b/tests/engine/battler_native_construction.lua @@ -0,0 +1,82 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Exercise the actual native constructor and switches, including real sprite +-- selection and animation factories. No replacement art or cloned battle loop. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local Version = require('src.core.GameVersion') +local Battler = require('src.battle.Battler') +local Screen = require('src.battle.BattleState') +local Pokemon = require('src.pokemon.Pokemon') +local Save = require('src.core.SaveData') +local Sprites = require('src.pokemon.Sprites') +local PicAnim = require('src.pokemon.PicAnim') +local MonAnim = require('src.pokemon.MonAnim') +local initial = Version.get() +local make, path, pic, anim = Battler.new, Sprites.path, PicAnim.forMon, MonAnim.new +local builds, paths, pics, anims +Battler.new = function(...) + local b = make(...) + T.eq(b.sprite, nil, 'shared native factory does not hydrate a sprite') + T.eq(b.picAnim, nil, 'shared native factory does not hydrate a pic animation') + T.eq(b.monAnim, nil, 'shared native factory does not hydrate a procedural animation') + local fields = {}; for key, value in pairs(b) do fields[key] = value end + builds[#builds+1] = {battler=b, fields=fields} + return b +end +Sprites.path = function(data, species, side, opts) + paths[#paths+1] = {species=species, side=side, mon=opts.mon, kind=opts.kind} + return path(data, species, side, opts) +end +PicAnim.forMon = function(data, mon, side) + local value = pic(data, mon, side) + pics[#pics+1] = {mon=mon, side=side, value=value} + return value +end +MonAnim.new = function(data, species) + local value = anim(data, species) + anims[#anims+1] = {species=species, value=value} + return value +end + +for _, edition in ipairs(Version.ORDER) do + Version.set(edition) + builds, paths, pics, anims = {}, {}, {}, {} + local data = T.fixtures.fresh(); data.constants.gen = Version.generation() + require('src.render.Font').load(data) + local save = Save.newGame() + save.party = {Pokemon.new(data, 'FIXMON_A', 25), Pokemon.new(data, 'FIXMON_B', 20)} + local game = {data=data, save=save, stack={top=function() end}} + local battle = Screen.newWild(game, 'FIXMON_C', 15) + T.eq(#builds, 2, edition .. ' real wild constructor uses shared data factory for both sides') + T.eq(builds[1].battler, battle.player, 'player uses the actual constructed table') + T.eq(builds[2].battler, battle.enemy, 'enemy uses the actual constructed table') + T.eq(battle.player.mon, save.party[1], 'native player retains save identity') + T.eq(#paths, 2, 'one actual battle picture selection per side') + T.eq(paths[1].side, 'back', 'player keeps native back picture') + T.eq(paths[2].side, 'front', 'enemy keeps native front picture') + T.eq(paths[1].mon, battle.player.mon, 'player picture sees actual form/shiny-bearing Pokemon') + T.eq(paths[2].mon, battle.enemy.mon, 'enemy picture sees actual form/shiny-bearing Pokemon') + T.eq(paths[1].kind, 'battle', 'actual sprite selector retains battle context') + T.eq(#pics, 1, 'ordinary player back picture does not acquire a front animation') + T.eq(pics[1].mon, battle.enemy.mon, 'native picture animation owns actual opponent') + T.eq(pics[1].value, battle.enemy.picAnim, 'native picture animation result is retained') + T.eq(#anims, 1, 'only the opponent gets native procedural animation construction') + T.eq(anims[1].value, battle.enemy.monAnim, 'native procedural animation result is retained') + for _, row in ipairs(builds) do + for key, value in pairs(row.fields) do + T.eq(row.battler[key], value, edition .. ' hydration preserves native field ' .. key) + end + end + local incoming = save.party[2] + battle:switchPlayerInto(Screen.POS.PLAYER_LEFT, incoming) + T.eq(#builds, 3, 'actual switch constructs a fresh transient battler') + T.eq(battle.player, builds[3].battler, 'actual switch uses shared factory result') + T.eq(battle.player.mon, incoming, 'actual switch retains live bench Pokemon') + T.eq(paths[3].mon, incoming, 'switched sprite selector receives live bench Pokemon') + T.eq(paths[3].side, 'back', 'switch retains native player picture direction') + T.eq(#pics, 1, 'ordinary back-picture switch does not allocate front animation') + T.eq(#anims, 1, 'player switch does not allocate opponent procedural animation') +end +Battler.new, Sprites.path, PicAnim.forMon, MonAnim.new = make, path, pic, anim +Version.set(initial) +T.finish('shared native battler construction and actual art hydration across editions') From 67a05ea1910b136dfc8a91a8862ee495550f041c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:39:46 +0100 Subject: [PATCH 165/187] Document offered-host battler construction proof and scope --- docs/native-battler-construction.md | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/native-battler-construction.md diff --git a/docs/native-battler-construction.md b/docs/native-battler-construction.md new file mode 100644 index 000000000..cedfb5684 --- /dev/null +++ b/docs/native-battler-construction.md @@ -0,0 +1,34 @@ +# Native battler construction without presentation resources + +The shared native `BattleState.makeBattler` previously built combat data, +selected pictures and created animation resources in one function. Extract +its data construction into `src/battle/Battler.lua`, and have the existing +screen constructor hydrate that same table with its original art factories. +There is no second turn simulator, replacement Pokemon or replacement art. + +This offered host retains its existing Gen2 calculation binding and poison +initialization. It does not import the fork-only `HeldItemRead` dependency +or silently introduce the fork's later toxic-counter behavior. Party/move/ +stat/registry identities, badge defaults, outside-battle sleep and form +personal records retain the existing native construction semantics. + +On implementation/test commit `281e5185`, fresh plain-Lua construction passes +577 checks without a LÖVE stub, with presentation-module loads rejected. +Actual wild construction and switches pass 810 checks across this host's +15 advertised editions using the real sprite and animation factories. +These are selected ROM-free checks, not complete current-host, GPU or +release qualification. The previous round-clock offer has separate scoped +RNG/link/model evidence; it does not qualify this new combined source. + +This is a prerequisite for sharing native battlers with standalone model +construction. It does not implement the complete source `Battle.new`, pure +turn/event executor, arbitrary writable model fields, full unchanged-mod +visual parity or final release acceptance. + +The data code is extracted from the existing engine; original authors' +rights and applicable licences remain effective. Extraction/integration +and tests by Roxas2712 are offered under +, +including its specific permission for Cedric / UNDERdecoded with credit. +That contribution notice does not replace earlier permissions or assign +ownership of the underlying engine. From 102f51440717a8946863f4124b1af4387da41137 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:56:21 +0100 Subject: [PATCH 166/187] Add source Game2 frame queries on the offered native viewport stack --- src/core/Game.lua | 23 + src/core/ModCompat.lua | 2 +- src/ui/Gen2Chrome.lua | 664 ++++++++++++++++++++++++++++ tests/engine/gen2_frame_queries.lua | 80 ++++ tests/engine/gen2_menu_chrome.lua | 79 ++++ 5 files changed, 847 insertions(+), 1 deletion(-) create mode 100644 src/ui/Gen2Chrome.lua create mode 100644 tests/engine/gen2_frame_queries.lua create mode 100644 tests/engine/gen2_menu_chrome.lua diff --git a/src/core/Game.lua b/src/core/Game.lua index 265ada8b2..b141fbb6c 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1056,6 +1056,29 @@ function Game:toggleFrameProfile() return FrameProfile.toggle() end +-- Source Game2 queries use the native Gen2 chrome's coordinate contract. +-- These are observations only: do not resize a canvas or change the active +-- compositor, skin rectangle, renderer scale or cartridge on a query. +function Game:frameFit(w, h) + local chrome = require('src.ui.Gen2Chrome') + local scale = chrome.fitScale(w, h) + local x, y = chrome.fitOrigin(w, h, scale) + local window = love and love.window + local dpi = window and window.getDPIScale and tonumber(window.getDPIScale()) or 1 + local pw, ph = GameViewport.pixelDimensions() + return scale, x, y, dpi, pw, ph +end + +function Game:viewport(w, h) + local scale, x, y, dpi = self:frameFit(w, h) + return { + width=w, height=h, gameX=x, gameY=y, + gameWidth=160*scale, gameHeight=144*scale, + scale=scale, dpiX=dpi, dpiY=dpi, + } +end + + function Game:_draw() -- the UI canvas clears transparent when the overworld's world pass -- shows through beneath it; opaque full-screen states get the classic diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..5b234b263 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -32,7 +32,6 @@ -- hook, not one module -- src.world.gen2.Follower PikachuFollower is Yellow's, not a general -- party follower --- src.ui.gen2.Chrome no shared window-chrome module -- src.ui.gen2.SaveMenu the save flow is part of StartMenu here -- src.ui.gen2.BattleAnimView Gen2AnimPlayer is a player, not a view -- src.battle.UIVisibility no equivalent @@ -80,6 +79,7 @@ ModCompat.ALIASES = { ["src.battle.gen2.Effects"] = "src.battle.MoveEffects", ["src.battle.gen2.Mon"] = "src.pokemon.Pokemon", ["src.ui.gen2.BattleState"] = "src.battle.BattleState", + ["src.ui.gen2.Chrome"] = "src.ui.Gen2Chrome", -- menus. The Gold port names two of these differently rather than -- namespacing them, so both spellings land on the one implementation. diff --git a/src/ui/Gen2Chrome.lua b/src/ui/Gen2Chrome.lua new file mode 100644 index 000000000..df557ba62 --- /dev/null +++ b/src/ui/Gen2Chrome.lua @@ -0,0 +1,664 @@ +-- Ported from Gen1Recomp 535a5ec0; palette, font and DPI use this engine. +-- Shared Gen 2 menu chrome: boxes, tile-grid text, and the scrolling cursor +-- list that nearly every Gold screen is built out of. +-- +-- Gold draws its menus through one routine (engine/menus/menu.asm SetUpMenu + +-- GetScrollingMenuJoypad), so the behaviour every screen inherits -- wrap at +-- the ends, a ▶ cursor in a one-tile gutter, B to back out, START as an +-- accept on some menus -- lives here once instead of in each screen. +-- +-- Everything is in 8px tile coordinates on the 20x18 GB grid, because that is +-- how the ASM addresses the screen (hlcoord x, y) and it makes a layout +-- transcribed from pokegold land on the same pixels. + +local Font = require("src.render.Font") +local PaletteFX = require("src.render.PaletteFX") +-- A view of the native palette renderer, with no extra mode or BGP state. +local Colors = {} +function Colors.available() return PaletteFX.shader() ~= nil end +function Colors.resolve(colors) return PaletteFX.effectiveColors(colors) end +function Colors.useRaw(colors) + local shader = PaletteFX.shader() + if not shader then return end + for i = 1, 4 do + local c = colors[i] + shader:send("c" .. (i - 1), { c[1] / 255, c[2] / 255, c[3] / 255 }) + end + love.graphics.setShader(shader) +end +function Colors.use(colors) Colors.useRaw(PaletteFX.effectiveColors(colors)) end + +local function drawGlyphCode(code, x, y, ink) + if code >= 0 then return Font.drawCode(code, x, y) end + -- This engine's UnicodeFont encodes rasterized glyphs as negative codes. + ink = ink or { 0, 0, 0 } + Font.pushStyle({ text = { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 } }) + local ok, err = pcall(Font.drawCode, code, x, y) + Font.popStyle() + if not ok then error(err, 0) end +end + +local WHITE = { 255, 255, 255 } +local BLACK = { 0, 0, 0 } + +local Chrome = {} + +-- charmap.asm: ▶ is the menu cursor, ▷ its hollow "held" form, ▼ the +-- text-advance arrow. +Chrome.CURSOR = 0xED +Chrome.CURSOR_HOLLOW = 0xEC +Chrome.DOWN_ARROW = 0xEE + +Chrome.SCREEN_W = 20 +Chrome.SCREEN_H = 18 + +-- charmap.asm "¥", the money field's own prefix tile. +local YEN = "\xc2\xa5" + +function Chrome.paletteFill(px, py, pw, ph, palette) + palette = palette or Chrome.DEFAULT_BOX_PALETTE + local G = love.graphics + G.setColor(1, 1, 1, 1) + if Colors.available() then + -- Colors.with without the closure: set, draw, restore. + local previous = G.getShader and G.getShader() or nil + Colors.use(palette) + G.rectangle("fill", px, py, pw, ph) + G.setShader(previous) + else + G.rectangle("fill", px, py, pw, ph) + end + G.setColor(0, 0, 0, 1) +end + +-- Every drawWidescreen paints the window around its 160x144 panel through +-- here, so UI LETTERBOX has exactly one place to override. r/g/b is what the +-- screen was authored with and is what AUTO keeps. +Chrome.worldSurround = false + +function Chrome.letterbox(winW, winH, r, g, b) + if Chrome.worldSurround then return end + love.graphics.setColor(r or 1, g or 1, b or 1, 1) + love.graphics.rectangle("fill", 0, 0, winW, winH) +end + +function Chrome.clear() + Chrome.paletteFill(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) +end + +-- The blit scale every `drawWidescreen` paints its 160x144 panel at. +-- +-- One GB pixel has to cover a WHOLE number of window pixels or the 8x8 grid +-- everything here is laid out on stops landing on tile boundaries: at a +-- fractional scale some rows of a tile get one device pixel and their +-- neighbours get two, which is what breaks a box border into steps and eats +-- pixel rows out of glyphs. This is the same rule src/render/Renderer.lua +-- fitScale applies to the Gen 1 UI canvas; the surround a widescreen screen +-- paints still fills the window, the PANEL is what stays on the grid. +local function playfieldRect(winW, winH) + local ok, Playfield = pcall(require, "src.render.Playfield") + if ok and Playfield.rect then + local okv, x, y, w, h = pcall(Playfield.rect, winW, winH) + if okv and w and w >= 1 and h and h >= 1 then + return x, y, w, h + end + end + return 0, 0, winW or 0, winH or 0 +end + +Chrome.playfieldRect = playfieldRect + +local function pixelRatio() + local get = love.graphics.getDPIScale or (love.window and love.window.getDPIScale) + local d = get and get() or 1 + return type(d) == "number" and d > 0 and d or 1 +end + +Chrome.pixelRatio = pixelRatio + +function Chrome.fitScaleFor(winW, winH, tilesW, tilesH) + local _, _, w, h = playfieldRect(winW, winH) + local d = pixelRatio() + return math.max(1, math.floor(math.min(w * d / (tilesW * 8), + h * d / (tilesH * 8)) + 1e-6)) / d +end + +function Chrome.fitOriginFor(winW, winH, scale, tilesW, tilesH) + scale = scale or Chrome.fitScaleFor(winW, winH, tilesW, tilesH) + local x, y, w, h = playfieldRect(winW, winH) + local d = pixelRatio() + return x + math.floor((w - tilesW * 8 * scale) * d / 2 + 1e-6) / d, + y + math.floor((h - tilesH * 8 * scale) * d / 2 + 1e-6) / d + - Chrome.positionLift(winW, winH, scale) +end + +function Chrome.fitScale(winW, winH) + return Chrome.fitScaleFor(winW, winH, Chrome.SCREEN_W, Chrome.SCREEN_H) +end + +-- The centred origin that goes with it, so a caller does not re-derive it. +function Chrome.fitOrigin(winW, winH, scale) + scale = scale or Chrome.fitScale(winW, winH) + return Chrome.fitOriginFor(winW, winH, scale, Chrome.SCREEN_W, + Chrome.SCREEN_H) +end + +function Chrome.positionLift(winW, winH, scale) + local ok, ScreenPosition = pcall(require, "src.core.ScreenPosition") + if not ok or ScreenPosition.skinActive(winW, winH) then return 0 end + local _, _, _, h = playfieldRect(winW, winH) + local d = pixelRatio() + return ScreenPosition.lift(h * d, Chrome.SCREEN_H * 8 * (scale + or Chrome.fitScale(winW, winH)) * d, ScreenPosition.safeTop() * d) / d +end + +-- pokegold engine/battle/core.asm:8646, engine/events/halloffame.asm:270 +local function clipTo(x, y, w, h) + local G = love.graphics + if G.transformPoint then + local x1, y1 = G.transformPoint(x, y) + local x2, y2 = G.transformPoint(x + w, y + h) + x, y = math.floor(math.min(x1, x2)), math.floor(math.min(y1, y2)) + w, h = math.ceil(math.abs(x2 - x1)), math.ceil(math.abs(y2 - y1)) + end + if G.intersectScissor then G.intersectScissor(x, y, w, h) + else G.setScissor(x, y, w, h) end +end + +Chrome.clipTo = clipTo + +function Chrome.withPanel(winW, winH, r, g, b, drawFn, scale) + local G = love.graphics + Chrome.letterbox(winW, winH, r, g, b) + scale = scale or Chrome.fitScale(winW, winH) + local ox, oy = Chrome.fitOrigin(winW, winH, scale) + G.push("all") + clipTo(ox, oy, Chrome.SCREEN_W * 8 * scale, Chrome.SCREEN_H * 8 * scale) + G.translate(ox, oy) + G.scale(scale, scale) + local ok, err = pcall(drawFn) + G.pop() + if not ok then error(err, 0) end +end + +function Chrome.withClip(drawFn) + local G = love.graphics + G.push("all") + clipTo(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + local ok, err = pcall(drawFn) + G.pop() + if not ok then error(err, 0) end +end + +Chrome.DEFAULT_BOX_PALETTE = { + { 255, 255, 255 }, { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 }, +} + +function Chrome.paletteBox(tx, ty, tw, th, palette) + palette = palette or Chrome.DEFAULT_BOX_PALETTE + if Colors.available() then + local G = love.graphics + G.setColor(1, 1, 1, 1) + -- Colors.with, restoring the caller's shader even if the box throws. + local previous = G.getShader and G.getShader() or nil + Colors.use(palette) + local ok, err = pcall(Font.drawBox, tx, ty, tw, th) + G.setShader(previous) + if not ok then error(err, 0) end + else + Font.drawBox(tx, ty, tw, th) + end + love.graphics.setColor(0, 0, 0, 1) +end + +-- A bordered box, tile coords. Leaves the draw color black for text. +function Chrome.box(tx, ty, tw, th) + Chrome.paletteBox(tx, ty, tw, th) +end + +-- Gold's Textbox helper takes an interior width/height and draws the border +-- around it; b/c in the ASM are interior rows/columns, so a `lb bc, 4, 13` +-- box is 6 rows by 15 columns on screen. +function Chrome.textbox(tx, ty, interiorW, interiorH) + Chrome.box(tx, ty, interiorW + 2, interiorH + 2) +end + +local function flatPrint(text, tx, ty) + love.graphics.setColor(0, 0, 0, 1) + return Font.draw(text, tx * 8, ty * 8) +end + +function Chrome.print(text, tx, ty) + if not Colors.available() then return flatPrint(text, tx, ty) end + return Chrome.printThrough(text, tx, ty, Chrome.DEFAULT_BOX_PALETTE) +end + +-- Prints a string the way a tilemap screen does: the glyph tiles replace the +-- cells they land on, and both their ink and their blank pixels go through +-- that screen's own BG palette. +-- +-- The font sheets here are black ink (shade 3) on transparent, and setColor +-- multiplies -- black times anything is still black -- so the ink colour +-- cannot come from a tint. Drawing through GbcPalette maps shade 3 to the +-- palette's colour 3, and painting colour 0 behind the string first supplies +-- the cell background the sheet does not carry. Without a shader this +-- degrades to the ordinary black print rather than drawing nothing. +-- +-- The palette printThrough actually draws with: COLOR mode resolved, +-- inverted if asked, and the active rBGP byte folded in last -- the same +-- order Colors.use follows. Split out so the fold can be checked +-- without a real shader (Colors.available() is false in a driverless +-- test harness, which used to hide this from every suite). +-- +-- The substitution has to happen before the reversal or an inverted string +-- would come back through an un-reversed grey ramp and print black on white, +-- and the rBGP fold has to come after both: it REORDERS the four entries +-- this palette already settled on (colour 0 in the low bits, so +-- `dc 3,2,1,0` packs to $e4), it does not tint them, so folding it before +-- the invert reversal would permute the wrong four colours. +function Chrome.throughPalette(palette, invert) + return PaletteFX.effectiveColors(palette, invert) +end + +function Chrome.rawPalette(palette, invert) + if invert then return { palette[4], palette[3], palette[2], palette[1] } end + return palette +end + +-- The per-glyph shader/tint switch printThrough needs, factored out so a +-- caller that positions its own glyphs (TextBox's typewriter/scroll +-- animation) gets the same shaded ink without re-deriving the TTF-vs-tile +-- split. +-- +-- Returns (pal, drawGlyph, finish): +-- pal the resolved ramp, or nil if there's no shader to draw +-- through; a caller that also draws a paper rect should skip +-- it and take the plain Chrome.print degrade instead. +-- drawGlyph (code, x, y): draws one glyph, shaded through `pal` for a +-- tile glyph or tinted with pal[4] for a TTF one (see the +-- inline comment below, gen1recomp#1642). +-- finish call once after the last glyph to restore the previous +-- shader and draw colour. +function Chrome.paletteGlyphs(palette, invert, raw) + if not (palette and Colors.available()) then + return nil, Font.drawCode, function() end + end + -- `pal` is used below and never touches the caller's palette again -- + -- including useRaw for the draw, since useRaw skips the fold Colors.use + -- would otherwise apply and folding it a second time would undo this. + local pal = raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) + local ink = pal[4] or { 0, 0, 0 } + local previous = love.graphics.getShader() + local shaded = false + local function drawGlyph(code, x, y) + if code < 0 then + -- flat-shaded 2bpp tile sheet -- exactly what a TTF glyph is not. + -- LÖVE's font rasterizer stores glyph coverage as alpha over a plain + -- white texture, which this shader reads back as shade 0 no matter how + -- solid the glyph looks, painting the character the SAME colour as the + -- paper rect just drawn above it: invisible (reported against a real + -- Gold build with a TTF translation mod active, gen1recomp#1642). A TTF + -- glyph has no discrete shade to recover in the first place, so skip + -- the shader for it and tint it with the palette's own ink colour + -- (shade 3, the same entry `rgb = pal3` would have mapped a black tile + -- pixel to) directly. + if shaded then love.graphics.setShader(previous); shaded = false end + love.graphics.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1) + elseif not shaded then + love.graphics.setColor(1, 1, 1, 1) + Colors.useRaw(pal) + shaded = true + end + drawGlyphCode(code, x, y, ink) + end + local function finish() + if shaded then love.graphics.setShader(previous) end + love.graphics.setColor(0, 0, 0, 1) + end + return pal, drawGlyph, finish +end + +-- printThrough's resolved palette, or nil when it has to take the plain +-- degrade: paletteGlyphs' own test and fold, without its two closures. +local function glyphPalette(palette, invert, raw) + if not (palette and Colors.available()) then return nil end + return raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) +end + +-- The string's glyph codes and its width, off one encode: the width is the +-- sum of the same advances the pen walks, which is all Font.width is. +local function measure(text) + local codes = Font.encode(text) + local width = 0 + for i = 1, #codes do width = width + Font.advanceOf(codes[i]) end + return codes, width +end + +-- paper rect, then every glyph through `pal` -- paletteGlyphs' drawGlyph and +-- finish inlined (see there for the TTF split, gen1recomp#1642). +local function drawThrough(codes, width, x, y, pal) + local G = love.graphics + local paper = pal[1] or WHITE + local ink = pal[4] or BLACK + local previous = G.getShader() + G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + G.rectangle("fill", x, y, width, 8) + local shaded = false + local pen = x + for i = 1, #codes do + local code = codes[i] + if code < 0 then + if shaded then G.setShader(previous); shaded = false end + G.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1) + elseif not shaded then + G.setColor(1, 1, 1, 1) + Colors.useRaw(pal) + shaded = true + end + drawGlyphCode(code, pen, y, ink) + pen = pen + Font.advanceOf(code) + end + if shaded then G.setShader(previous) end + G.setColor(0, 0, 0, 1) +end + +function Chrome.printThrough(text, tx, ty, palette, invert, raw) + local pal = glyphPalette(palette, invert, raw) + if not pal then return Chrome.print(text, tx, ty) end + local codes, width = measure(text) + drawThrough(codes, width, tx * 8, ty * 8, pal) + return width +end + +-- The same, through an *inverted* font page. Pokedex_LoadInvertedFont xors +-- both bitplanes of the standard font, so a glyph pixel of shade s becomes +-- shade 3 - s before the palette sees it -- which is exactly the palette read +-- backwards, and is what makes the dex white on black. +function Chrome.printInverted(text, tx, ty, palette) + if not palette then return Chrome.print(text, tx, ty) end + return Chrome.printThrough(text, tx, ty, palette, true) +end + +-- Right-aligned within a field that ends at tile `txEnd` (exclusive), which is +-- how Gold prints numbers (PrintNum fills from the right). +local function flatPrintRight(text, txEnd, ty) + local width = Font.width(text) + love.graphics.setColor(0, 0, 0, 1) + return Font.draw(text, txEnd * 8 - width, ty * 8) +end + +function Chrome.printRight(text, txEnd, ty) + if not Colors.available() then return flatPrintRight(text, txEnd, ty) end + return Chrome.printRightThrough(text, txEnd, ty, Chrome.DEFAULT_BOX_PALETTE) +end + +function Chrome.printRightThrough(text, txEnd, ty, palette, invert, raw) + local pal = glyphPalette(palette, invert, raw) + if not pal then return Chrome.printRight(text, txEnd, ty) end + local codes, width = measure(text) + drawThrough(codes, width, txEnd * 8 - width, ty * 8, pal) + return width +end + +-- Wrap text to `width` tiles, measuring with the real font so a proportional +-- page wraps where it actually overflows. Nothing on a 160px screen may print +-- past tile 20: text that does is drawn outside the GB frame entirely. +-- A "\n" in the text is the cart's OWN line break (data/text's `line` / `next` +-- control byte, e.g. "\nused !"), so it is a HARD break: each +-- segment wraps on its own and the break survives, rather than being eaten by +-- the %S+ tokenizer and re-wrapped by width. +function Chrome.wrap(text, width) + local budget = (width or Chrome.SCREEN_W) * 8 + local lines = {} + for segment in (tostring(text or "") .. "\n"):gmatch("(.-)\n") do + local line = nil + for word in segment:gmatch("%S+") do + local candidate = line and (line .. " " .. word) or word + if line and Font.width(candidate) > budget then + lines[#lines + 1] = line + line = word + else + line = candidate + end + end + if line then lines[#lines + 1] = line end + end + return lines +end + +-- Print wrapped text from (tx, ty) downward, at most `rows` lines. +-- ../pokecrystal/home/text.asm:473 +function Chrome.printWrapped(text, tx, ty, width, rows, step, palette) + step = step or 1 + local lines = Chrome.wrap(text, width) + for i = 1, math.min(#lines, rows or #lines) do + if palette then + Chrome.printThrough(lines[i], tx, ty + (i - 1) * step, palette) + else + Chrome.print(lines[i], tx, ty + (i - 1) * step) + end + end + return #lines +end + +local function flatCursor(tx, ty, hollow) + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8) +end + +function Chrome.cursor(tx, ty, hollow) + if not Colors.available() then return flatCursor(tx, ty, hollow) end + return Chrome.cursorThrough(tx, ty, Chrome.DEFAULT_BOX_PALETTE, nil, hollow) +end + +-- The cursor glyph through a palette, the way Chrome.printThrough draws text. +-- A screen whose font page is inverted (the #DEX: Pokedex_LoadInvertedFont +-- xors both bitplanes, so it runs white on black) needs its cursor inverted +-- with everything else -- Chrome.cursor's flat black is invisible against that +-- ground, which is what made the dex's action arrow look absent. +function Chrome.cursorThrough(tx, ty, palette, invert, hollow, raw) + if not (palette and Colors.available()) then + return Chrome.cursor(tx, ty, hollow) + end + local pal = raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) + local paper = pal[1] or { 255, 255, 255 } + love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + love.graphics.rectangle("fill", tx * 8, ty * 8, 8, 8) + love.graphics.setColor(1, 1, 1, 1) + local previous = love.graphics.getShader() + Colors.useRaw(pal) + Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8) + love.graphics.setShader(previous) + love.graphics.setColor(0, 0, 0, 1) +end + +-- Gold pads a numeric field with spaces, not zeroes, unless +-- PRINTNUM_LEADINGZEROS is set. +function Chrome.number(value, width, leadingZeros) + local text = tostring(math.floor(value or 0)) + local pad = math.max(0, (width or 0) - #text) + return (leadingZeros and ("0"):rep(pad) or (" "):rep(pad)) .. text +end + +-- PrintNum with PRINTNUM_MONEY and without PRINTNUM_LEADINGZEROS +-- (home/print_num.asm .PrintYen): the ¥ is emitted just before the FIRST +-- significant digit rather than at a fixed column, and the field is six digits +-- wide, so the string is always seven tiles and the yen sign floats. +function Chrome.money(amount) + local digits = ("%06d"):format(math.max(0, math.floor(amount or 0))) + local first = digits:find("[1-9]") or #digits + return (" "):rep(first - 1) .. YEN .. digits:sub(first) +end + +-- ------------------------------------------------------- balance boxes +-- +-- engine/menus/menu_2.asm. These three are the boxes a SCRIPT puts up with a +-- `special` and then leaves standing: every one of them is immediately +-- followed by `loadmenu`, so the box is on screen for the whole of the static +-- menu that answers. They live here rather than in one menu module because +-- three different screens print them (the Game Corner prize counters and the +-- coin vendor through src/ui/gen2/ScriptMenu.lua, the prize counter screen +-- itself) and all three are laid out by the same two routines. +-- +-- DisplayCoinCaseBalance: Textbox at (11,0) with a 7x1 interior, "COIN" at +-- (12,0) -- yes, in the border row -- and the four-digit count at (13,1) with +-- PRINTNUM_LEADINGZEROS. +function Chrome.coinBalanceBox(coins) + Chrome.textbox(11, 0, 7, 1) + Chrome.printThrough("COIN", 12, 0, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(coins, 4, true), 13, 1, Chrome.DEFAULT_BOX_PALETTE) +end + +-- DisplayMoneyAndCoinBalance: one Textbox at (5,0) with a 13x3 interior +-- holding both fields, "MONEY" at (6,1) with the yen field at (12,1) and +-- "COIN" at (6,3) with the four-digit count at (15,3). +function Chrome.moneyAndCoinBalanceBox(money, coins) + Chrome.textbox(5, 0, 13, 3) + Chrome.printThrough("MONEY", 6, 1, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("COIN", 6, 3, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(coins, 4, true), 15, 3, Chrome.DEFAULT_BOX_PALETTE) +end + +-- PlaceMoneyTopRight: MoneyTopRightMenuHeader is `menu_coords 11, 0, +-- SCREEN_WIDTH - 1, 2`, a MenuBox rather than a Textbox, and PlaceMoneyTextbox +-- prints the number at MenuBoxCoord2Tile + SCREEN_WIDTH + 1, i.e. (12,1). +function Chrome.moneyBalanceBox(money) + Chrome.box(11, 0, 9, 3) + Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE) +end + +-- A vertical cursor list. +-- +-- opts: +-- items array of strings, or of { label = , value = , disabled = } +-- x, y tile coords of the first row's *label* (cursor sits at x - 1) +-- spacing tile rows between entries (Gold uses 2 for most menus) +-- rows visible rows; a longer list scrolls (default: all) +-- wrap wrap past the ends (STATICMENU_WRAP) +-- startAccepts treat START like A (STATICMENU_ENABLE_START) +-- onChoose(value, index) +-- onCancel() +-- onMove(value, index) -- for the start menu's description box +local List = {} +List.__index = List +Chrome.List = List + +function List.new(opts) + local self = setmetatable({}, List) + self.items = {} + for i, entry in ipairs(opts.items or {}) do + if type(entry) == "table" then + self.items[i] = entry + else + self.items[i] = { label = tostring(entry), value = entry } + end + end + self.x = opts.x or 1 + self.y = opts.y or 1 + self.spacing = opts.spacing or 2 + self.rows = math.min(opts.rows or #self.items, #self.items) + self.wrap = opts.wrap ~= false + self.startAccepts = opts.startAccepts or false + self.onChoose = opts.onChoose + self.onCancel = opts.onCancel + self.onMove = opts.onMove + self.palette = opts.palette == nil and Chrome.DEFAULT_BOX_PALETTE or opts.palette + self.index = math.max(1, math.min(opts.index or 1, math.max(1, #self.items))) + self.scroll = 0 + self:ensureVisible() + return self +end + +function List:current() + return self.items[self.index] +end + +function List:ensureVisible() + if self.rows <= 0 then return end + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + self.rows then + self.scroll = self.index - self.rows + end + self.scroll = math.max(0, math.min(self.scroll, #self.items - self.rows)) +end + +function List:move(delta) + if #self.items == 0 then return end + local next_ = self.index + delta + if next_ < 1 then + if not self.wrap then return end + next_ = #self.items + elseif next_ > #self.items then + if not self.wrap then return end + next_ = 1 + end + self.index = next_ + self:ensureVisible() + if self.onMove then self.onMove(self:current() and self:current().value, self.index) end +end + +-- Returns true when the press was consumed, so a screen can layer its own +-- handling (left/right on the options rows) behind this. +function List:update(input) + if not input then return false end + if input:wasPressed("up") then + self:move(-1) + return true + elseif input:wasPressed("down") then + self:move(1) + return true + elseif input:wasPressed("a") + or (self.startAccepts and input:wasPressed("start")) then + local item = self:current() + if item and not item.disabled and self.onChoose then + self.onChoose(item.value, self.index) + end + return true + elseif input:wasPressed("b") then + if self.onCancel then self.onCancel() end + return true + end + return false +end + +function List:draw() + for row = 1, self.rows do + local i = row + self.scroll + local item = self.items[i] + if item then + local ty = self.y + (row - 1) * self.spacing + if i == self.index then + if self.palette then + Chrome.cursorThrough(self.x - 1, ty, self.palette) + else + flatCursor(self.x - 1, ty) + end + end + if self.palette then + Chrome.printThrough(item.label, self.x, ty, self.palette) + else + flatPrint(item.label, self.x, ty) + end + end + end + -- Scrolling lists get the ▼ hint Gold shows when there is more below. + if self.rows < #self.items and self.scroll + self.rows < #self.items then + if self.palette then + local _, drawGlyph, finish = Chrome.paletteGlyphs(self.palette) + drawGlyph(Chrome.DOWN_ARROW, (self.x - 1) * 8, + (self.y + self.rows * self.spacing - 1) * 8) + finish() + else + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8, + (self.y + self.rows * self.spacing - 1) * 8) + end + end +end + +return Chrome diff --git a/tests/engine/gen2_frame_queries.lua b/tests/engine/gen2_frame_queries.lua new file mode 100644 index 000000000..391ae2422 --- /dev/null +++ b/tests/engine/gen2_frame_queries.lua @@ -0,0 +1,80 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Public source Game2 geometry must agree with the actual native classic +-- compositor, retain logical/pixel units and remain a passive query. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local Game = require('src.core.Game') +local Renderer = require('src.render.Renderer') +local Version = require('src.core.GameVersion') +local Viewport = require('src.render.GameViewport') +local initial = Version.get() +local available = type(Game.frameFit)=='function' and type(Game.viewport)=='function' +T.check(available, 'native Game2 owner exposes frameFit and viewport queries') +if not available then T.finish('source Game2 frame queries are absent') end +local graphics, window = love.graphics, love.window +local dimensions, pixels, gdpi = graphics.getDimensions, graphics.getPixelDimensions, graphics.getDPIScale +local wdpi = window.getDPIScale +local oldUIW, oldUIH = Renderer.uiWidth, Renderer.uiHeight +local oldFill, oldSplit, oldSkin = Renderer.uiFill, Renderer.splitViewport, Viewport.skinFill +Renderer.uiWidth, Renderer.uiHeight = 160, 144 +Renderer.uiFill, Renderer.splitViewport, Viewport.skinFill = false, false, false +Renderer:init() +local game = setmetatable({}, {__index=Game}) + +for _, edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'}) do + Version.set(edition) + local data = T.fixtures.fresh(); data.constants.gen = 2 + local run = T.sdk.loadMods({'mods/frame_probe'}, {data=data, fs=T.sdk.memfs({ + ['mods/frame_probe/manifest.json']='{"id":"frame_probe","name":"Frame probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/frame_probe/main.lua']=[[ + local mod=... + local Game2=require('src.core.Game2') + mod.exports.fit=function(game,w,h)return Game2.frameFit(game,w,h)end + mod.exports.viewport=function(game,w,h)return Game2.viewport(game,w,h)end + ]], + })}) + T.eq(#run.errors, 0, edition .. ' actual SDK Game2 geometry consumer loads') + local probe = assert(run.loader.exports.frame_probe) + for _, size in ipairs({{160,144},{320,240},{640,576},{1024,768},{1280,960},{720,1280}}) do + local w, h = size[1], size[2] + for _, dpi in ipairs({1,1.25,1.5,2,3}) do + graphics.getDimensions = function() return w,h end + graphics.getPixelDimensions = function() return w*dpi,h*dpi end + graphics.getDPIScale, window.getDPIScale = function() return dpi end, function() return dpi end + local before = Renderer:endFrame() + local scale,x,y,d,pw,ph = probe.fit(game,w,h) + local view = probe.viewport(game,w,h) + T.eq(scale, (before.gameWidth/160), 'source scale agrees with actual native logical draw scale') + T.eq(x, before.gameX, 'source horizontal origin agrees with native composition') + T.eq(y, before.gameY, 'source vertical origin agrees with native composition') + T.eq(d, dpi, 'source query reports the actual window DPI') + T.eq(pw, w*dpi, 'source query retains drawable pixel width') + T.eq(ph, h*dpi, 'source query retains drawable pixel height') + T.eq(view.width, w, 'public viewport retains logical window width') + T.eq(view.height, h, 'public viewport retains logical window height') + T.eq(view.gameWidth, before.gameWidth, 'public playfield width matches native composition') + T.eq(view.gameHeight, before.gameHeight, 'public playfield height matches native composition') + T.eq(view.gameX, before.gameX, 'public playfield horizontal origin matches native composition') + T.eq(view.gameY, before.gameY, 'public playfield vertical origin matches native composition') + T.eq(view.scale, scale, 'source viewport uses its LOVE-unit scale') + T.eq(view.dpiX, dpi, 'source viewport retains DPI X') + T.eq(view.dpiY, dpi, 'source viewport retains DPI Y') + T.check(math.abs(x*dpi-math.floor(x*dpi+.5))<1e-8, 'horizontal origin lands on a physical pixel') + T.check(math.abs(y*dpi-math.floor(y*dpi+.5))<1e-8, 'vertical origin lands on a physical pixel') + view.gameX = -1000; view.scale = -1 + local again = probe.viewport(game,w,h) + T.eq(again.gameX, before.gameX, 'returned view cannot mutate the next query') + T.eq(again.scale, (before.gameWidth/160), 'returned scale cannot mutate the next query') + T.same(Renderer:endFrame(), before, 'public queries do not mutate native frame fitting') + T.eq(Version.get(), edition, 'public query does not change active cartridge') + end + end + T.eq(#run.errors, 0, 'actual SDK query did not swallow an engine error') + run.release() +end +graphics.getDimensions, graphics.getPixelDimensions, graphics.getDPIScale = dimensions, pixels, gdpi +window.getDPIScale = wdpi +Renderer.uiWidth, Renderer.uiHeight = oldUIW, oldUIH +Renderer.uiFill, Renderer.splitViewport, Viewport.skinFill = oldFill, oldSplit, oldSkin +Version.set(initial) +T.finish('source Game2 frame queries through the SDK and actual native compositor') diff --git a/tests/engine/gen2_menu_chrome.lua b/tests/engine/gen2_menu_chrome.lua new file mode 100644 index 000000000..f1fd4be61 --- /dev/null +++ b/tests/engine/gen2_menu_chrome.lua @@ -0,0 +1,79 @@ +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local P = require('src.render.PaletteFX') +local Font = require('src.render.Font') +local V = require('src.core.GameVersion') +local C = require('src.ui.Gen2Chrome') +local before = {mode=P.mode, shade=P.shadeMap(), version=V.get()} +require('src.core.ModCompat').install() +T.eq(require('src.ui.gen2.Chrome'),C,'public Gen2 name resolves actual native menu renderer') +Font.load(T.fixtures.fresh()) +local ramp={{250,240,230},{180,170,160},{100,90,80},{20,10,0}} +local map={[0]=1,[1]=3,[2]=0,[3]=2} +P.setShadeMap(map) +for _,version in ipairs({'gold','silver','crystal','red','firered','emerald'})do + V.set(version) + for _,mode in ipairs({'gbc','gbc_inv','og','og_inv','classic'})do + P.setMode(mode) + T.same(C.throughPalette(ramp),P.effectiveColors(ramp),'menu uses actual native mode/register '..version..'/'..mode) + -- Inversion belongs BEFORE the noncommuting shade map. Compare against + -- the native resolved display ramp with the shade register disarmed. + P.setShadeMap(nil);local settled=P.effectiveColors(ramp);P.setShadeMap(map) + local reversed={settled[4],settled[3],settled[2],settled[1]} + T.same(C.throughPalette(ramp,true),P.permute(reversed,map),'inverted text then hardware shade register '..version..'/'..mode) + T.same(C.rawPalette(ramp,true),{ramp[4],ramp[3],ramp[2],ramp[1]},'raw palette skips native display substitutions') + end +end +P.setShadeMap(nil);P.setMode('gbc');V.set('crystal') +local dpi=love.graphics.getDPIScale +for _,d in ipairs({1,1.5,2})do + love.graphics.getDPIScale=function()return d end -- output metrics only + local scale=C.fitScale(801,601) + T.eq(scale*d,math.floor(scale*d),'native panel pixels land on framebuffer grid') + local x,y=C.fitOrigin(801,601,scale) + T.check(x>=0 and y>=0 and x+160*scale<=801 and y+144*scale<=601,'native panel fits its actual viewport') +end +love.graphics.getDPIScale=dpi +T.eq(C.number(23,4,false),' 23','native padded number') +T.eq(C.number(23,4,true),'0023','native zero-filled number') +T.eq(C.money(23),' ¥23','native floating yen field') +T.same(C.wrap('ONE TWO\nTHREE',7),{'ONE TWO','THREE'},'cart hard line break retained') +local chosen,cancel,moved +local list=C.List.new({items={{label='ONE',value=1},{label='TWO',value=2,disabled=true},{label='THREE',value=3}},rows=2,onChoose=function(v)chosen=v end,onCancel=function()cancel=true end,onMove=function(v)moved=v end}) +local input=require('src.core.Input');input:init() +local function tap(key)input:keypressed(key);input:step();local out=list:update(input);input:keyreleased(key);input:step();return out end +T.check(tap('down'),'real native down edge consumed') +T.eq(list.index,2,'list moves once per native edge');T.eq(moved,2,'movement callback follows actual item') +tap('z');T.eq(chosen,nil,'disabled entry cannot be selected') +tap('down');T.eq(list.scroll,1,'native list scrolls into actual item') +tap('z');T.eq(chosen,3,'real native A selects current item') +tap('down');T.eq(list.index,1,'native list wraps at end') +tap('x');T.eq(cancel,true,'real native B cancels list') + +-- Real native box and font calls, with only the output APIs recorded. +local draw,rect=love.graphics.draw,love.graphics.rectangle +local drawn,filled=0,0 +love.graphics.draw=function(...)drawn=drawn+1;return draw(...)end +love.graphics.rectangle=function(...)filled=filled+1;return rect(...)end +C.clear();C.textbox(0,12,18,4);C.print('ONE',1,13);C.cursor(1,1);list:draw() +T.check(drawn>0 and filled>0,'native chrome actually renders font, box and paper') +local shader={tag='caller'};love.graphics.setShader(shader) +T.raises(function()C.withPanel(800,600,1,1,1,function()love.graphics.setShader({});error('panel body probe')end)end,'panel body probe','panel propagates actual draw failure') +T.eq(love.graphics.getShader(),shader,'panel restores native graphics state on failure') +love.graphics.setShader();love.graphics.draw,love.graphics.rectangle=draw,rect +-- Exercise the shaded path too; only the GPU uniform sink is replaced. +local nativeShader=P.shader +local uniforms={} +local output={send=function(_,key,value)uniforms[key]=value end} +P.shader=function()return output end +P.setShadeMap(map) +local caller={tag='native caller'};love.graphics.setShader(caller) +local expected=P.effectiveColors(ramp,true) +C.printThrough('ONE',1,1,ramp,true) +T.same(uniforms.c0,{expected[1][1]/255,expected[1][2]/255,expected[1][3]/255},'glyph shader receives once-resolved native paper') +T.same(uniforms.c3,{expected[4][1]/255,expected[4][2]/255,expected[4][3]/255},'glyph shader receives once-resolved native ink') +T.eq(love.graphics.getShader(),caller,'palette text restores actual caller shader') +T.eq(P.shadeMap(),map,'chrome does not replace native shade register') +P.shader=nativeShader;love.graphics.setShader() +P.setMode(before.mode);P.setShadeMap(before.shade);V.set(before.version) +T.finish('native Gen2 shared menu chrome') From 5e4ee81ffdb2ea45fee9b1f160838e0a1ba5b242 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:07:33 +0100 Subject: [PATCH 167/187] Include the native palette prerequisite from the Chrome offer --- src/render/PaletteFX.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 88984d94b..4bdfe5f67 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -1155,7 +1155,7 @@ function PaletteFX.paperShade(data) return c[1] / 255, c[2] / 255, c[3] / 255 end -function PaletteFX.effectiveColors(c) +function PaletteFX.effectiveColors(c, invert) if not c then return nil end local mode = PaletteFX.mode or "gbc" local out = c @@ -1176,6 +1176,10 @@ function PaletteFX.effectiveColors(c) -- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too -- (#322). permute() is the identity (and returns `out` itself, which the -- mod-graphics parity check leans on) while nothing has armed one. + -- An inverted font page reverses its settled display ramp before the + -- hardware shade register permutes it. Keep this on the native path so + -- Gen2 menu chrome cannot carry a second palette mode/register state. + if invert then out = { out[4], out[3], out[2], out[1] } end return PaletteFX.permute(out, shadeMap) end From 7dd02913673216e3a35cc48ef8321cba9f8aee09 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:09:27 +0100 Subject: [PATCH 168/187] Document coherent viewport and Chrome prerequisites with actual-host tests --- docs/native-gen2-frame-queries.md | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/native-gen2-frame-queries.md diff --git a/docs/native-gen2-frame-queries.md b/docs/native-gen2-frame-queries.md new file mode 100644 index 000000000..5c5d520b3 --- /dev/null +++ b/docs/native-gen2-frame-queries.md @@ -0,0 +1,43 @@ +# Native source Game2 frame queries + +`src.core.Game2` resolves to native `Game`, but the native class lacks the +source `frameFit(w,h)` and `viewport(w,h)` observations. An unchanged Gen2 +scene therefore uses a fallback instead of asking its source owner. + +Add passive queries through the existing Gen2 chrome and native viewport +owners: logical scale/origin, window DPI, actual drawable dimensions and +a detached classic 160x144 viewport result. They do not replace native +composition, mutate the cartridge or allocate another scene renderer. + +This offer is based on the native viewport/touch stack in PR383. It includes +the already offered Chrome component from PR224 with all of its required +pieces: `Gen2Chrome`, its module alias, its palette-owner extension and its +tests. Inverted font ramps must be reversed after display-mode selection +and before the hardware shade-register permutation; the optional native +`effectiveColors(c,invert)` argument supplies that operation without a +second palette state. Calls without the optional flag retain their behavior. + +Selected validation on offered implementation `5e4ee81f`: + +- 3161 frame-query checks through the actual API2 SDK and the actual older + native `Renderer:endFrame` compositor, across five GSC editions, six + window shapes and DPI 1, 1.25, 1.5, 2 and 3. The newer fork's `frameRects` + observation helper is not invented on this older host. +- 116 native menu-chrome checks, including inverted palettes with a + noncommuting shade map, real font/box drawing and input/list behavior. +- The same frame-query probe fails cleanly on the preceding PR383 source + `a3f0d344` because the two methods are missing. + +Complete combined-host tests, every custom skin/cutout/safe-area and odd +fractional-DPI configuration, physical Windows behavior, matched unchanged +mod GPU/actor pinning and release acceptance remain open. The concrete +missing API is established; this does not yet prove the cause or correction +of a particular clipped command card. + +Source contract reviewed in Gen1Recomp `535a5ec0`, `src/core/Game2.lua`. +The existing Chrome component retains its original provenance and licence +conditions. Original engine authors' rights remain effective. Integration +and tests by Roxas2712 are offered under +, +including its specific Cedric / UNDERdecoded permission with credit; this +does not replace earlier grants or assign ownership of the underlying engine. From d36520347a5417c990645ce9f7968c96f11b9aad Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:27:17 +0100 Subject: [PATCH 169/187] Share compositor pixel fit for actual drawable-size queries --- src/core/Game.lua | 18 ++++++++++-- src/render/PixelFit.lua | 16 +++++++++++ src/render/Renderer.lua | 11 ++++---- tests/engine/gen2_frame_queries.lua | 44 +++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 src/render/PixelFit.lua diff --git a/src/core/Game.lua b/src/core/Game.lua index b141fbb6c..8a73004ad 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1060,12 +1060,24 @@ end -- These are observations only: do not resize a canvas or change the active -- compositor, skin rectangle, renderer scale or cartridge on a query. function Game:frameFit(w, h) - local chrome = require('src.ui.Gen2Chrome') - local scale = chrome.fitScale(w, h) - local x, y = chrome.fitOrigin(w, h, scale) local window = love and love.window local dpi = window and window.getDPIScale and tonumber(window.getDPIScale()) or 1 local pw, ph = GameViewport.pixelDimensions() + local ww, wh = GameViewport.dimensions() + -- Source battle scenes query their actual drawable size, whereas menus + -- query LOVE-unit dimensions. Do not multiply a framebuffer query by DPI + -- a second time. Share the compositor's integer-pixel policy and origin. + if w == pw and h == ph and (pw ~= ww or ph ~= wh) then + local fit = require('src.render.PixelFit') + local surfaceW, surfaceH = Renderer:uiSize() + local scale = surfaceW == 160 and surfaceH == 144 and Renderer:fitScale() + or fit.scale(pw, ph, 160, 144) + local x, y = fit.origin(pw, ph, 160, 144, scale) + return scale, x, y, dpi, pw, ph + end + local chrome = require('src.ui.Gen2Chrome') + local scale = chrome.fitScale(w, h) + local x, y = chrome.fitOrigin(w, h, scale) return scale, x, y, dpi, pw, ph end diff --git a/src/render/PixelFit.lua b/src/render/PixelFit.lua new file mode 100644 index 000000000..f9e2206bb --- /dev/null +++ b/src/render/PixelFit.lua @@ -0,0 +1,16 @@ +-- Native framebuffer fitting, extracted from Renderer. +-- Existing engine authors' rights/licences remain effective (see the original engine licences). +-- Integration contributions by Roxas2712; see +-- https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +local Fit = {} + +function Fit.scale(pixelW, pixelH, surfaceW, surfaceH) + return math.max(1, math.floor(math.min(pixelW / surfaceW, pixelH / surfaceH))) +end + +function Fit.origin(pixelW, pixelH, surfaceW, surfaceH, scale) + return math.floor((pixelW - surfaceW * scale) / 2), + math.floor((pixelH - surfaceH * scale) / 2) +end + +return Fit diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index e251985f6..693932276 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -11,6 +11,7 @@ local Tilt = require("src.render.Tilt") local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") +local PixelFit = require("src.render.PixelFit") local Runtime = require("src.mods.Runtime") local Output = require("src.render.Output") local GameViewport = require("src.render.GameViewport") @@ -150,7 +151,7 @@ end function Renderer:fitScale() local _, _, pw, ph = displayMetrics() local w, h = self:uiSize() - return math.max(1, math.floor(math.min(pw / w, ph / h))) + return PixelFit.scale(pw, ph, w, h) end -- Integer framebuffer pixels per GB pixel for the UI pass. @@ -839,8 +840,8 @@ function Renderer:endFrame(zones, worldZones) local uiw, uih = self:uiSize() local vpw, vph = uiw * Sx, uih * Sy -- Snap the letterbox origin to a framebuffer pixel, then convert to units. - local ox = math.floor((pw - uiw * Sp) / 2) / dpiX - local oy = math.floor((ph - uih * Sp) / 2) / dpiY + local pixelX, pixelY = PixelFit.origin(pw, ph, uiw, uih, Sp) + local ox, oy = pixelX / dpiX, pixelY / dpiY -- The UI has its own scale: it steps down as the survey zoom goes out (see -- uiScale), so it can be smaller than the world letterbox. Un-zoomed these -- are identical to Sp/ox/oy and every rect below is what it always was. @@ -856,8 +857,8 @@ function Renderer:endFrame(zones, worldZones) end local Ux, Uy = Up / dpiX, Up / dpiY local uvpw, uvph = uiw * Ux, uih * Uy - local uox = math.floor((pw - uiw * Up) / 2) / dpiX - local uoy = math.floor((ph - uih * Up) / 2) / dpiY + local uiPixelX, uiPixelY = PixelFit.origin(pw, ph, uiw, uih, Up) + local uox, uoy = uiPixelX / dpiX, uiPixelY / dpiY self.uiPresentation = {x=uox,y=uoy,w=uvpw,h=uvph,scaleX=Ux,scaleY=Uy} local GBCFX = gbcFX() -- Forced mono/Classic modes still need a whole-screen zone when a state diff --git a/tests/engine/gen2_frame_queries.lua b/tests/engine/gen2_frame_queries.lua index 391ae2422..34f33f31c 100644 --- a/tests/engine/gen2_frame_queries.lua +++ b/tests/engine/gen2_frame_queries.lua @@ -61,6 +61,25 @@ for _, edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'}) T.eq(view.dpiY, dpi, 'source viewport retains DPI Y') T.check(math.abs(x*dpi-math.floor(x*dpi+.5))<1e-8, 'horizontal origin lands on a physical pixel') T.check(math.abs(y*dpi-math.floor(y*dpi+.5))<1e-8, 'vertical origin lands on a physical pixel') + -- Unchanged battle scenes pass drawable dimensions instead of window + -- dimensions. Those queries must use pixel coordinates exactly once. + local ps,px,py,pd,qpw,qph = probe.fit(game,w*dpi,h*dpi) + local pview = probe.viewport(game,w*dpi,h*dpi) + T.eq(ps, before.scale, 'drawable query uses actual integer framebuffer scale') + T.check(math.abs(px-before.gameX*dpi)<1e-8, 'drawable query matches native pixel origin X') + T.check(math.abs(py-before.gameY*dpi)<1e-8, 'drawable query matches native pixel origin Y') + T.eq(pd, dpi, 'drawable query retains actual DPI metadata') + T.eq(qpw, w*dpi, 'drawable query retains actual pixel width') + T.eq(qph, h*dpi, 'drawable query retains actual pixel height') + T.check(math.abs(pview.gameWidth-before.gameWidth*dpi)<1e-8, 'drawable viewport width matches native pixels') + T.check(math.abs(pview.gameHeight-before.gameHeight*dpi)<1e-8, 'drawable viewport height matches native pixels') + if edition=='crystal' and w==1024 and h==768 and dpi==2 then + -- Independent real Metal observation: native scale10/origin224,48; + -- the rejected API candidate instead returned10.5/origin184,12. + T.eq(ps, 10, 'actual Retina regression scale') + T.eq(px, 224, 'actual Retina regression horizontal origin') + T.eq(py, 48, 'actual Retina regression vertical origin') + end view.gameX = -1000; view.scale = -1 local again = probe.viewport(game,w,h) T.eq(again.gameX, before.gameX, 'returned view cannot mutate the next query') @@ -69,6 +88,31 @@ for _, edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'}) T.eq(Version.get(), edition, 'public query does not change active cartridge') end end + for _,size in ipairs({{801,601,1.25},{1001,751,2},{1137,803,1.5}}) do + local w,h,dpi = size[1],size[2],size[3] + local pw,ph = math.floor(w*dpi),math.floor(h*dpi) + graphics.getDimensions = function() return w,h end + graphics.getPixelDimensions = function() return pw,ph end + graphics.getDPIScale, window.getDPIScale = function() return dpi end, function() return dpi end + local native = Renderer:endFrame() + local scale,x,y = probe.fit(game,pw,ph) + T.eq(scale, native.scale, 'odd drawable size retains actual native pixel scale') + T.check(math.abs(x-native.gameX*native.dpiX)<1e-8, 'odd drawable size retains native pixel origin X') + T.check(math.abs(y-native.gameY*native.dpiY)<1e-8, 'odd drawable size retains native pixel origin Y') + T.eq(x%1, 0, 'odd drawable horizontal origin is a complete pixel') + T.eq(y%1, 0, 'odd drawable vertical origin is a complete pixel') + end + graphics.getDimensions = function() return 1024,768 end + graphics.getPixelDimensions = function() return 2048,1536 end + graphics.getDPIScale, window.getDPIScale = function() return 2 end, function() return 2 end + local nativeFit = Renderer.fitScale + Renderer.fitScale = function() return 3 end + local native = Renderer:endFrame() + local scale,x,y = probe.fit(game,2048,1536) + T.eq(scale, native.scale, 'drawable query observes actual decorated native fit') + T.eq(x, native.gameX*2, 'decorated native horizontal origin remains authoritative') + T.eq(y, native.gameY*2, 'decorated native vertical origin remains authoritative') + Renderer.fitScale = nativeFit T.eq(#run.errors, 0, 'actual SDK query did not swallow an engine error') run.release() end From 0a45bbbb2ddbbb6b8d3567fab48b0332b22627ff Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:41:05 +0100 Subject: [PATCH 170/187] Record actual drawable regression and corrected older-host qualification --- docs/native-gen2-frame-queries.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/native-gen2-frame-queries.md b/docs/native-gen2-frame-queries.md index 5c5d520b3..4f3af94e7 100644 --- a/docs/native-gen2-frame-queries.md +++ b/docs/native-gen2-frame-queries.md @@ -8,6 +8,9 @@ Add passive queries through the existing Gen2 chrome and native viewport owners: logical scale/origin, window DPI, actual drawable dimensions and a detached classic 160x144 viewport result. They do not replace native composition, mutate the cartridge or allocate another scene renderer. +Actual drawable-size queries use framebuffer coordinates. The shared +`PixelFit` helper extracts the existing compositor scale/origin arithmetic; +drawable inputs must not receive a second DPI multiplication. This offer is based on the native viewport/touch stack in PR383. It includes the already offered Chrome component from PR224 with all of its required @@ -17,7 +20,7 @@ and before the hardware shade-register permutation; the optional native `effectiveColors(c,invert)` argument supplies that operation without a second palette state. Calls without the optional flag retain their behavior. -Selected validation on offered implementation `5e4ee81f`: +Historical selected validation on offered implementation `5e4ee81f`: - 3161 frame-query checks through the actual API2 SDK and the actual older native `Renderer:endFrame` compositor, across five GSC editions, six @@ -28,8 +31,23 @@ Selected validation on offered implementation `5e4ee81f`: - The same frame-query probe fails cleanly on the preceding PR383 source `a3f0d344` because the two methods are missing. +The newer fork's real Metal test then exposed a case those initial logical +queries did not cover: unchanged VASC passes2048x1536 framebuffer dimensions +in a1024x768 DPI2 window. The initial query returned scale10.5/origin184,12; +the actual native compositor uses pixel scale10/origin224,48. That initial +candidate is rejected for coordinate acceptance. The expanded probe fails +263 of4454 checks on that fork candidate. + +Offered correction `d3652034` independently passes4454 frame-query checks +through this older host's actual compositor and116 menu-chrome checks. +The expanded probe includes actual drawable arguments, the recorded Retina +regression, odd pixel dimensions and decorated native fitting. The same +shared-policy correction passes the newer fork's4454 query checks plus +its existing window/playfield/chrome regressions. Actual corrected GPU +observations and complete current-tree qualification remain pending. + Complete combined-host tests, every custom skin/cutout/safe-area and odd -fractional-DPI configuration, physical Windows behavior, matched unchanged +fractional-DPI logical configuration, physical Windows behavior, matched unchanged mod GPU/actor pinning and release acceptance remain open. The concrete missing API is established; this does not yet prove the cause or correction of a particular clipped command card. From fb19487a68eff85f402deea61a1cd2ee1a93e40a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:59:50 +0100 Subject: [PATCH 171/187] Document corrected fork Metal observations without older-host GPU claim --- docs/native-gen2-frame-queries.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/native-gen2-frame-queries.md b/docs/native-gen2-frame-queries.md index 4f3af94e7..a544159ff 100644 --- a/docs/native-gen2-frame-queries.md +++ b/docs/native-gen2-frame-queries.md @@ -43,8 +43,17 @@ through this older host's actual compositor and116 menu-chrome checks. The expanded probe includes actual drawable arguments, the recorded Retina regression, odd pixel dimensions and decorated native fitting. The same shared-policy correction passes the newer fork's4454 query checks plus -its existing window/playfield/chrome regressions. Actual corrected GPU -observations and complete current-tree qualification remain pending. +its existing window/playfield/chrome regressions. + +The newer fork's corrected source `123b8036` also passes a fresh actual Metal +checkpoint CONTINUE, connected walk and natural encounter with unchanged +VASC. Its observed drawable query returns scale10/origin224,48, matching +the native compositor. Four inspected owned OS images cover both selected +field camera flags; both select the same authored battle-stage view. +All 27336 mod files, their QA copies, the ROM and the earned source checkpoint +are unchanged. This is evidence for the shared coordinate correction on the +newer fork, not an actual GPU qualification of this older offered host. +Bottom command art remains partly clipped; full visual parity is rejected. Complete combined-host tests, every custom skin/cutout/safe-area and odd fractional-DPI logical configuration, physical Windows behavior, matched unchanged From 9417dfb456f9b47543b2b74ab797758b9299c2a2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:13:17 +0100 Subject: [PATCH 172/187] Add source screen positioning to the older native compositor and options owner --- src/core/Game.lua | 5 +++ src/core/ScreenPosition.lua | 84 +++++++++++++++++++++++++++++++++++++ src/render/Renderer.lua | 17 ++++++-- src/ui/OptionsMenu.lua | 11 +++++ 4 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 src/core/ScreenPosition.lua diff --git a/src/core/Game.lua b/src/core/Game.lua index 8a73004ad..dff454d70 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1073,6 +1073,10 @@ function Game:frameFit(w, h) local scale = surfaceW == 160 and surfaceH == 144 and Renderer:fitScale() or fit.scale(pw, ph, 160, 144) local x, y = fit.origin(pw, ph, 160, 144, scale) + if not Renderer.splitViewport then + y = y - require('src.core.ScreenPosition').pixelLift(ww, wh, ph, + 144 * scale, wh > 0 and ph / wh or dpi) + end return scale, x, y, dpi, pw, ph end local chrome = require('src.ui.Gen2Chrome') @@ -1989,6 +1993,7 @@ function Game:applyOptions(opts) -- after VideoMode: a faithful-resolution lock is an exact window size, so -- it has to be the last word on the window (it drops fullscreen to hold) require("src.core.FaithfulRes").applyOptions(opts) + require("src.core.ScreenPosition").applyOptions(opts) -- normalizes a nil/garbage cap to the 60 default, so old saves with no -- fpsCap key pace at the standard rate (issue #88) require("src.core.FrameCap").applyOptions(opts) diff --git a/src/core/ScreenPosition.lua b/src/core/ScreenPosition.lua new file mode 100644 index 000000000..bf96b6339 --- /dev/null +++ b/src/core/ScreenPosition.lua @@ -0,0 +1,84 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Copyright (C) 2026 BOIS CLUB GAMES, LLC. +-- GPLv3 with Section 7 terms retained in licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Native viewport/pixel integration by Roxas2712 under the same terms. +local ScreenPosition = {} + +ScreenPosition.MODES = { "center", "upper", "top" } +ScreenPosition.DEFAULT = "center" +ScreenPosition.mode = ScreenPosition.DEFAULT + +local LABELS = { center = "CENTER", upper = "UPPER", top = "TOP" } + +function ScreenPosition.normalize(v) + if LABELS[v] then return v end + return ScreenPosition.DEFAULT +end + +function ScreenPosition.label(v) + if ScreenPosition.skinActive() then return "SKIN" end + return LABELS[ScreenPosition.normalize(v)] +end + +function ScreenPosition.cycle(v, dir) + if ScreenPosition.skinActive() then return ScreenPosition.normalize(v) end + v = ScreenPosition.normalize(v) + local modes = ScreenPosition.MODES + local cur = 1 + for i, mode in ipairs(modes) do + if mode == v then cur = i break end + end + return modes[(cur - 1 + (dir or 1)) % #modes + 1] +end + +function ScreenPosition.setMode(v) + ScreenPosition.mode = ScreenPosition.normalize(v) +end + +function ScreenPosition.applyOptions(opts) + ScreenPosition.setMode(opts and opts.screenPos) +end + +function ScreenPosition.safeTop() + local ok, SafeArea = pcall(require, "src.core.SafeArea") + if not ok then return 0 end + local okr, _, y = pcall(SafeArea.rect) + if not okr then return 0 end + return math.max(0, tonumber(y) or 0) +end + +function ScreenPosition.skinActive(w, h) + local ok, TouchSkin = pcall(require, "src.core.TouchSkin") + if not ok or type(TouchSkin.viewport) ~= "function" then return false end + if (not w or not h) and love and love.graphics and love.graphics.getDimensions then + w, h = love.graphics.getDimensions() + end + local okv, x = pcall(TouchSkin.viewport, w, h) + return okv and x ~= nil +end + +function ScreenPosition.lift(viewH, contentH, safeTop) + if ScreenPosition.mode == "center" then return 0 end + viewH = tonumber(viewH) or 0 + contentH = tonumber(contentH) or 0 + local slack = viewH - contentH + if slack <= 0 then return 0 end + local centered = math.floor(slack / 2) + local target = ScreenPosition.mode == "top" and 0 or math.floor(slack / 4) + safeTop = math.floor(tonumber(safeTop) or 0) + if safeTop > 0 and target < safeTop then + target = math.min(safeTop, centered) + end + return centered - target +end + +-- The native compositor measures physical pixels; safe-area ownership stays +-- with the current logical viewport. Skin/split layouts keep their own origin. +function ScreenPosition.pixelLift(windowW, windowH, pixelH, contentH, dpiY) + if ScreenPosition.mode == "center" then return 0 end + local Viewport = require("src.render.GameViewport") + if Viewport.active() or ScreenPosition.skinActive(windowW, windowH) then return 0 end + return ScreenPosition.lift(pixelH, contentH, ScreenPosition.safeTop() * dpiY) +end + +return ScreenPosition diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 693932276..408d93b95 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local PixelFit = require("src.render.PixelFit") +local ScreenPosition = require("src.core.ScreenPosition") local Runtime = require("src.mods.Runtime") local Output = require("src.render.Output") local GameViewport = require("src.render.GameViewport") @@ -278,9 +279,15 @@ function Renderer:setWorldBounds(w, h) end function Renderer:worldViewSize() - local _, _, pw, ph = displayMetrics() - local sp = Zoom.scale(self:fitScale()) + local ww, wh, pw, ph, _, dpiY = displayMetrics() + local fitted = self:fitScale() + local sp = Zoom.scale(fitted) local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp) + local _, uih = self:uiSize() + local lift = not self.splitViewport and ScreenPosition.pixelLift(ww, wh, ph, + uih * fitted, dpiY) or 0 + -- Preserve full world coverage below the raised camera. + if lift > 0 then vh = vh + 2 * math.ceil(lift / sp) end -- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral), -- so unfloored FX/sprite math cannot phase-shimmer against the tile layer. if vw % 2 ~= 0 then vw = vw + 1 end @@ -841,6 +848,9 @@ function Renderer:endFrame(zones, worldZones) local vpw, vph = uiw * Sx, uih * Sy -- Snap the letterbox origin to a framebuffer pixel, then convert to units. local pixelX, pixelY = PixelFit.origin(pw, ph, uiw, uih, Sp) + local lift = not self.splitViewport and ScreenPosition.pixelLift(ww, wh, ph, + uih * Sp, dpiY) or 0 + pixelY = pixelY - lift local ox, oy = pixelX / dpiX, pixelY / dpiY -- The UI has its own scale: it steps down as the survey zoom goes out (see -- uiScale), so it can be smaller than the world letterbox. Un-zoomed these @@ -858,6 +868,7 @@ function Renderer:endFrame(zones, worldZones) local Ux, Uy = Up / dpiX, Up / dpiY local uvpw, uvph = uiw * Ux, uih * Uy local uiPixelX, uiPixelY = PixelFit.origin(pw, ph, uiw, uih, Up) + if lift > 0 then uiPixelY = math.max(0, uiPixelY - lift) end local uox, uoy = uiPixelX / dpiX, uiPixelY / dpiY self.uiPresentation = {x=uox,y=uoy,w=uvpw,h=uvph,scaleX=Ux,scaleY=Uy} local GBCFX = gbcFX() @@ -1109,7 +1120,7 @@ function Renderer:endFrame(zones, worldZones) sp = self:worldPresentationScale(sp, pw, ph, wvw, wvh) sx, sy = sp / dpiX, sp / dpiY local wox = math.floor((pw - wvw * sp) / 2) / dpiX - local woy = math.floor((ph - wvh * sp) / 2) / dpiY + local woy = (math.floor((ph - wvh * sp) / 2) - lift) / dpiY -- Tilt mode projects the ground world pass through the perspective mesh -- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone -- scissoring here). drawTiltedWorld returns false when tilt is off or diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d69ff5714..c0df451a5 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -20,6 +20,7 @@ local GameSpeed = require("src.core.GameSpeed") local GameVersion = require("src.core.GameVersion") local VideoMode = require("src.core.VideoMode") local FaithfulRes = require("src.core.FaithfulRes") +local ScreenPosition = require("src.core.ScreenPosition") local FrameCap = require("src.core.FrameCap") local Performance = require("src.core.Performance") local Logger = require("src.core.Logger") @@ -462,6 +463,16 @@ local function buildRows(game) FaithfulRes.apply(o.faithfulRes) return true end }, + { id = "screenPos", label = Strings("SCREEN POS"), + value = function(g) + return Strings(ScreenPosition.label(g.save.options.screenPos)) + end, + step = function(g, dir) + local o = g.save.options + o.screenPos = ScreenPosition.cycle(o.screenPos, dir) + ScreenPosition.setMode(o.screenPos) + return true + end }, -- hard render cap (issue #88): bounds the present rate so a -- driver-forced vsync-off run cannot spin at thousands of FPS. Logic -- is fixed-step off dt, so this touches presentation only. From de6b4ed4b7526e96dacde2fcca8f310b77014e44 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:13:30 +0100 Subject: [PATCH 173/187] Cover older native screen positioning through actual composition and input --- tests/engine/native_screen_position.lua | 181 ++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tests/engine/native_screen_position.lua diff --git a/tests/engine/native_screen_position.lua b/tests/engine/native_screen_position.lua new file mode 100644 index 000000000..4071b029c --- /dev/null +++ b/tests/engine/native_screen_position.lua @@ -0,0 +1,181 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Real native composition and option-input contracts, without a GPU. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local ok,Position=pcall(require,'src.core.ScreenPosition') +T.check(ok and type(Position)=='table','native source screen-position module exists') +if not ok then T.finish('native screen position is absent') end +local Renderer=require('src.render.Renderer') +local Game=require('src.core.Game') +local Viewport=require('src.render.GameViewport') +local Chrome=require('src.ui.Gen2Chrome') +local Skin=require('src.core.TouchSkin') +local Version=require('src.core.GameVersion') +local Safe=require('src.core.SafeArea') +local Zoom=require('src.render.Zoom') +local Tilt=require('src.render.Tilt') +local G=love.graphics +local previous={G.getDimensions,G.getPixelDimensions,G.getDPIScale,love.window.getDPIScale, + Safe.rect,Skin.active,Version.get(),Zoom.offset,Position.mode} +local width,height,dpi=360,640,1 +local function metrics(w,h,d) + width,height,dpi=w,h,d + G.getDimensions=function()return width,height end + G.getPixelDimensions=function()return width*dpi,height*dpi end + G.getDPIScale=function()return dpi end + love.window.getDPIScale=G.getDPIScale +end +metrics(width,height,dpi) +Safe.rect=function()return 0,0,width,height end +Skin.setActive(nil);Viewport.reset();Renderer:init();Zoom.offset=0 +Tilt.setLevel(0) + +T.eq(Position.normalize(nil),'center');T.eq(Position.normalize('bogus'),'center') +T.eq(Position.cycle('center',-1),'top');T.eq(Position.cycle('top',1),'center') +Position.setMode('top') +T.eq(Position.lift(640,288),176,'TOP places the centered content at zero') +T.eq(Position.lift(640,288,40),136,'TOP respects the safe inset') +T.eq(Position.lift(640,288,999),0,'unsafe excessive inset cannot move below center') +T.eq(Position.lift(144,288),0,'oversized content cannot be raised') +Position.setMode('upper');T.eq(Position.lift(640,288),88,'UPPER uses upper-half center') +T.eq(Position.lift(640,288,120),56,'UPPER also respects the safe inset') + +-- This offered host has no frameRects observation API. Exercise its actual +-- final compositor and normalize only the returned test observations. +local function composed() + local out=Renderer:endFrame() + local _,uih=Renderer:uiSize() + return {Sp=out.scale,ph=height*dpi,uih=uih,ox=out.gameX,oy=out.gameY, + uoy=Renderer.uiPresentation.y} +end +local query=setmetatable({},{__index=Game}) +for _,edition in ipairs{'red','gold','silver','crystal','prism','polishedcrystal','firered','emerald'}do + Version.set(edition) + for _,size in ipairs{{360,640,1},{360,640,2},{801,1001,1.5},{1280,960,2},{160,144,1}}do + metrics(unpack(size));Position.setMode('center') + local center=composed() + local _,baseHeight=Renderer:worldViewSize() + for _,mode in ipairs{'center','upper','top'}do + Position.setMode(mode) + local r=composed() + local slack=r.ph-r.uih*r.Sp + local expected=0 + if slack>0 and mode~='center' then + expected=math.floor(slack/2)-(mode=='top' and 0 or math.floor(slack/4)) + end + T.eq(r.ox,center.ox,'screen position does not move horizontal origin') + T.eq(r.Sp,center.Sp,'screen position does not change integer zoom') + T.eq(r.oy,center.oy-expected/dpi,'world/UI anchor uses the selected vertical position') + T.eq(r.uoy,center.uoy-expected/dpi,'unzoomed UI follows the same anchor') + local _,vh=Renderer:worldViewSize() + T.eq(vh,baseHeight+2*math.ceil(expected/Zoom.scale(r.Sp)), + 'native world canvas grows enough to cover the raised camera') + if Version.isGen2()then + local s,x,y=Game.frameFit(query,width*dpi,height*dpi) + T.eq(s,r.Sp,'source pixel query keeps compositor scale') + T.check(math.abs(x-r.ox*dpi)<1e-8,'source pixel query shares compositor origin X') + T.check(math.abs(y-r.oy*dpi)<1e-8,'source pixel query shares compositor origin Y') + local cx,cy=Chrome.fitOrigin(width,height) + T.check(math.abs(cx-r.ox)<1e-8,'native Gold menu uses positioned logical origin X') + T.check(math.abs(cy-r.oy)<1e-8,'native Gold menu uses positioned logical origin Y') + end + end + end +end + +-- Inspect the actual final world/UI blits, not only the rectangle query. +metrics(360,640,2);Version.set('crystal');Position.setMode('top') +Renderer:init() +local vw,vh=Renderer:worldViewSize() +Renderer.worldCanvas=G.newCanvas(vw,vh) +Renderer.worldActive=true +local nativeDraw=G.draw +local worldDraw +G.draw=function(image,x,y,rotation,sx,sy,...) + if image==Renderer.worldCanvas then worldDraw={x=x,y=y,sx=sx,sy=sy}end + return nativeDraw(image,x,y,rotation,sx,sy,...) +end +local final=Renderer:endFrame() +G.draw=nativeDraw +T.check(worldDraw~=nil,'actual compositor submits the native world canvas') +if worldDraw then + T.check(worldDraw.y<=0,'raised world still covers the top edge') + T.check(worldDraw.y+vh*worldDraw.sy>=height,'grown world still covers the bottom edge') + T.eq(worldDraw.y,(math.floor((height*dpi-vh*4)/2)-352)/dpi, + 'actual native world blit subtracts the same physical lift') +end +T.eq(final.gameY,0,'actual final classic UI is raised to the top') +T.eq(Renderer.uiPresentation.y,0,'actual final UI blit uses the raised origin') + +-- A safe area owns the minimum top inset in LOVE units, then the compositor +-- snaps the chosen origin to physical pixels. No OS display setting changes. +metrics(360,640,2);Version.set('crystal');Position.setMode('top') +Safe.rect=function()return 0,40,width,height-40 end +local safe=composed() +T.eq(safe.oy,40,'actual compositor stops at a 40-unit safe inset on Retina') +local _,_,pixelY=Game.frameFit(query,720,1280) +T.eq(pixelY,80,'source drawable query reports the same inset in pixels') +Safe.rect=function()return 0,0,width,height end + +-- Skin-owned rectangles must win over the stored position. Exercise the real +-- native skin parser and viewport query rather than a fake presence flag. +local skin=assert(Skin.parse([[ +overlays = 1 +overlay0_name = "position-test" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_viewport = "0.1,0.1,0.8,0.4" +overlay0_descs = 1 +overlay0_desc0 = "nul,0.5,0.5,rect,0.02,0.02" +]])) +Skin.setActive(skin);Skin.setOverlayLive(false) +T.eq(Position.label('top'),'SKIN','option label names the skin owner') +T.eq(Position.cycle('top',1),'top','skin keeps stored position without cycling') +T.eq(composed().oy,math.floor((height*dpi-144*Renderer:fitScale())/2)/dpi, + 'actual older compositor leaves the skin-owned origin centered') +Skin.setActive(nil) +Renderer.splitViewport={w=360,h=640} +T.eq(composed().oy,math.floor((height*dpi-144*Renderer:fitScale())/2)/dpi, + 'actual older compositor preserves split-screen origin') +Renderer.splitViewport=nil + +-- Normal option input, native save codec and actual boot application. +local Menu=require('src.ui.OptionsMenu') +local Stack=require('src.core.StateStack') +local Save=require('src.core.SaveData') +for _,edition in ipairs{'gold','silver','crystal'}do + Version.set(edition);Position.setMode('center') + local g={data=T.fixtures.fresh(),save=Save.newGame(),writes=0,input={pressed={}}} + g.stack=setmetatable({},{__index=Stack});g.stack:init() + g.input.wasPressed=function(self,key)return self.pressed[key]==true end + g.writeOptions=function(self)self.writes=self.writes+1;self.persisted=Save.encode(self.save)end + local menu=Menu.new(g);g.stack:push(menu) + -- Actual older menu is flat: reach the new descriptor with ordinary + -- DOWN input; do not invent the newer fork's focusRow/group methods. + for _=1,#menu.rows do + if menu.rows[menu.index] and menu.rows[menu.index].id=='screenPos' then break end + g.input.pressed={down=true};menu:update(1/60);g.input.pressed={} + end + local page=menu + local found=menu.rows[menu.index] and menu.rows[menu.index].id=='screenPos' + T.check(found,'SCREEN POS is reachable through actual older native menu input') + if found then + T.eq(menu.rows[menu.index].id,'screenPos') + for _,mode in ipairs{'upper','top','center'}do + g.input.pressed={right=true};page:update(1/60);g.input.pressed={} + T.eq(g.save.options.screenPos,mode,'real right input updates the stored option') + T.eq(Position.mode,mode,'real input immediately updates native fitting') + end + T.eq(g.writes,3,'one normal native options write per input change') + local saved=assert(Save.decode(g.persisted)) + Position.setMode('top');Game.applyOptions(g,saved.options) + T.eq(Position.mode,'center','actual boot applies the persisted position') + saved.options.screenPos='upper';Game.applyOptions(g,saved.options) + T.eq(Position.mode,'upper','actual boot restores a nondefault position') + end +end + +Position.setMode('center');Viewport.reset();Skin.setActive(previous[6]) +G.getDimensions,G.getPixelDimensions,G.getDPIScale,love.window.getDPIScale=unpack(previous,1,4) +Safe.rect=previous[5];Version.set(previous[7]);Zoom.offset=previous[8];Position.setMode(previous[9]) +T.finish('native screen position: compositor, source queries, safe areas, skins and saved menu input') From b83c8fa4f1325d0dbeea170a368ad4c5f2a31fae Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:18:18 +0100 Subject: [PATCH 174/187] Document older-host screen-position prerequisites and pending qualification --- docs/native-screen-position.md | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/native-screen-position.md diff --git a/docs/native-screen-position.md b/docs/native-screen-position.md new file mode 100644 index 000000000..7b1448e77 --- /dev/null +++ b/docs/native-screen-position.md @@ -0,0 +1,45 @@ +# Native screen positioning + +The source Gen1/Gen2 option `screenPos` selects CENTER, UPPER or TOP. The +newer fork listed that key in its Gen2 video group but had no descriptor, +runtime module or compositor application. This older native host also lacks +the setting and source helper. Add the actual option, apply it on load, +and keep the default CENTER geometry. + +`ScreenPosition` retains the source normalization/cycling/lift contract. +The native renderer applies the lift in whole framebuffer pixels, converts +it using its actual vertical DPI, and shares the result between world and +UI origins. The world canvas grows below the raised camera to keep covering +the window. Source Game2 drawable queries and native Gold menu queries +observe the same positioned rectangle. Safe-area top insets bound the lift; +active touch-skin and native split-screen owners keep their own origin. +Selecting a position while a skin owns the viewport leaves the stored +selection intact and displays SKIN. + +The source helper is adapted from Gen1Recomp revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, copyright BOIS CLUB GAMES, LLC, +under the retained GPLv3/Section 7 terms in +`licenses/GEN1RECOMP-TOUCH-SKINS.md`. Native integration by Roxas2712 retains +those terms. No mod, protected Gen1 install or player profile is changed. + +Implementation `f137c679` passes 1,145 selected contract checks across eight +editions, portrait/landscape/Retina sizes, all three positions, safe insets, +the actual skin parser, source queries and native option input/save/boot +application. Existing frame-query 4,454, chrome 116, viewport 56, skin 59, +playfield 585 and battle-window 315 checks also pass. A later probe adds +actual final world/UI blit observations; that extended probe has not yet +been run at this checkpoint. + +Complete composed-tree tests, actual GPU/window acceptance, every custom +skin/device and final release qualification remain open. This offer is stacked on PR397 and its existing viewport/chrome prerequisites. +It uses this older host's actual `Renderer:endFrame` and flat options menu, +without adding the newer fork's `frameRects` or grouped-menu methods. Its +independent qualification is pending; the newer fork's 1,145 checks alone do +not qualify this offered host. + +Existing engine authors' rights remain effective. Independently owned integration/test +contributions by Roxas2712 are offered under +, +including the specific Cedric / UNDERdecoded permission with visible credit. +The source helper and its integration retain their existing GPLv3/Section 7 +provenance and terms; that offer does not narrow earlier grants. From 4353003f77908ad92156ac43578c87f73f2b8bfe Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:23:56 +0100 Subject: [PATCH 175/187] Record independent older-host screen-position and actual-blit contracts --- docs/native-screen-position.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/native-screen-position.md b/docs/native-screen-position.md index 7b1448e77..1364c9e2e 100644 --- a/docs/native-screen-position.md +++ b/docs/native-screen-position.md @@ -33,9 +33,12 @@ been run at this checkpoint. Complete composed-tree tests, actual GPU/window acceptance, every custom skin/device and final release qualification remain open. This offer is stacked on PR397 and its existing viewport/chrome prerequisites. It uses this older host's actual `Renderer:endFrame` and flat options menu, -without adding the newer fork's `frameRects` or grouped-menu methods. Its -independent qualification is pending; the newer fork's 1,145 checks alone do -not qualify this offered host. +without adding the newer fork's `frameRects` or grouped-menu methods. Its independent qualification on offered source `b83c8fa4` passes 1,031 +screen-position checks, including actual world/UI blits and real flat-menu +input/save/boot application. Its existing 4,454 frame-query and 116 chrome +checks also pass. The managed frozen host is cleaned after successful exit. +These are selected plain-Lua/compositor-stub proofs, not GPU or full-suite +qualification of this older host. Existing engine authors' rights remain effective. Independently owned integration/test contributions by Roxas2712 are offered under From 3fed24d0b97d1804534ba73b884637350c427da7 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:57:07 +0100 Subject: [PATCH 176/187] Expose GSC Pokemon fields through actual older native records and registry folds --- src/battle/BattleState.lua | 1 + src/core/Data.lua | 1 + src/core/SaveData.lua | 3 + src/mods/Loader.lua | 4 + src/pokemon/Gen2MonFields.lua | 116 ++++++++++++++++++++++++ src/pokemon/Pokemon.lua | 3 + src/pokemon/Stats.lua | 4 + tests/engine/gen2_mon_fields_data.lua | 109 ++++++++++++++++++++++ tests/engine/gen2_mon_fields_native.lua | 112 +++++++++++++++++++++++ 9 files changed, 353 insertions(+) create mode 100644 src/pokemon/Gen2MonFields.lua create mode 100644 tests/engine/gen2_mon_fields_data.lua create mode 100644 tests/engine/gen2_mon_fields_native.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c2f51594a..3d203411a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -978,6 +978,7 @@ end -- --------------------------------------------------------------------- local function makeBattler(data, mon, isPlayer, save) + if GameVersion.isGen2() then require('src.pokemon.Gen2MonFields').bind(data,mon) end local def = require('src.pokemon.Gen4Forms').definition(data,mon) local badgeBoosts = data.constants and data.constants.badgeBoosts local badges = nil diff --git a/src/core/Data.lua b/src/core/Data.lua index 26a842ce5..02971ba64 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -1877,6 +1877,7 @@ function Data:seedDefaults() end seedMissingGen2Tilesets(self) self:publishGen2Palettes() + require('src.pokemon.Gen2MonFields').publish(self) ensureGen2WarpFallbacks(self) ensureGen2HomeTextFallbacks(self) end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index e134442c0..9c893026e 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1997,6 +1997,9 @@ local function scrubKnownMon(mon, data) -- so the derived stats use a sane level. A save that already has stats is -- untouched. Stats.ensure(data.pokemon and data.pokemon[mon.species], mon) + if require('src.core.GameVersion').isGen2() then + require('src.pokemon.Gen2MonFields').bind(data,mon) + end local moves = mon.moves if type(moves) ~= "table" then return end local hadMoves = #moves > 0 diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 13ab6e048..a1715c01b 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1406,6 +1406,7 @@ function Loader:load(data) -- wraps and flags the previous load put on engine tables come off first. ModSeams.unwindAll() self.baseData = data + require('src.pokemon.Gen2MonFields').publish(data) -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later for _, registry in pairs(self.content) do @@ -1641,6 +1642,9 @@ function Loader:load(data) stampAudioOwners(data, name, registry) end end + -- Native registry folds replace their tables. Reapply source stat views + -- after the real older writer, before exposing the completed dataset. + require('src.pokemon.Gen2MonFields').publish(data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then diff --git a/src/pokemon/Gen2MonFields.lua b/src/pokemon/Gen2MonFields.lua new file mode 100644 index 000000000..f4f46eaf0 --- /dev/null +++ b/src/pokemon/Gen2MonFields.lua @@ -0,0 +1,116 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Source GSC field names on the actual native Pokemon/stat tables. Derived +-- aliases are never duplicated into saves and keep observing native writes. +local Fields = {} +local installed = setmetatable({}, {__mode='k'}) +local dataKey, baseKey = {}, {} +-- Both entries are weak: a value retaining its dataset must not root that +-- weak key on LuaJIT. Live Pokemon retain their own dataset via the metatable. +local monPlain = setmetatable({}, {__mode='kv'}) +local cached = { + stats=setmetatable({}, {__mode='kv'}), +} +local plain = {} +local statNames = {specialAttack='spatk',specialDefense='spdef'} + +local function read(index, object, key) + if type(index)=='function' then return index(object,key) end + if type(index)=='table' then return index[key] end +end +local function write(index, object, key, value) + if rawget(object,key)~=nil then rawset(object,key,value);return end + if type(index)=='function' then return index(object,key,value) end + if type(index)=='table' then index[key]=value;return end + rawset(object,key,value) +end + +local function getter(kind, object, key, previous, data) + if kind=='stats' then + local native=statNames[key] + if native then + local value=rawget(object,native) + if value~=nil then return value end + return read(previous,object,native) + end + elseif key=='maxHp' then + local stats=rawget(object,'stats') + return type(stats)=='table' and stats.hp or nil + elseif key=='name' or key=='types' then + local def=data and data.pokemon and data.pokemon[object.species] + if def then return key=='name' and (def.name or object.species) or def.types end + end +end + +local function metatableFor(kind, previous, data) + local found + if kind=='mon' then + if not previous then + if data then found=monPlain[data] else found=plain.mon end + end + elseif previous then found=cached[kind][previous] + else found=plain[kind] end + if found then return found end + local meta={} + for key,value in pairs(previous or {})do meta[key]=value end + if kind=='mon' then meta[dataKey],meta[baseKey]=data,previous or false end + local oldIndex,oldWrite=meta.__index,meta.__newindex + meta.__index=function(object,key) + local value=getter(kind,object,key,oldIndex,data) + if value~=nil then return value end + return read(oldIndex,object,key) + end + meta.__newindex=function(object,key,value) + if kind=='stats' and statNames[key] then + return write(oldWrite,object,statNames[key],value) + elseif kind=='mon' and key=='maxHp' and type(rawget(object,'stats'))=='table' then + object.stats.hp=value;return + end + return write(oldWrite,object,key,value) + end + installed[meta]=kind + if kind=='mon' then + if not previous then + if data then monPlain[data]=meta else plain.mon=meta end + end + elseif previous then cached[kind][previous]=meta else plain[kind]=meta end + return meta +end + +local function attach(object, kind, data) + if type(object)~='table' then return false end + local meta=getmetatable(object) + if installed[meta]==kind then + if kind~='mon' or rawget(meta,dataKey)==data then return true end + meta=rawget(meta,baseKey) or nil + end + -- A mod owns a protected custom metatable. Do not break its authority. + if meta~=nil and type(meta)~='table' then return false end + if meta and rawget(meta,'__metatable')~=nil then return false end + setmetatable(object,metatableFor(kind,meta,data)) + return true +end + +function Fields.stats(stats) + attach(stats,'stats') + return stats +end + +function Fields.bind(data, mon) + if type(mon)~='table' then return mon,false end + local attached=attach(mon,'mon',data) + local stats=rawget(mon,'stats') + if type(stats)=='table' then Fields.stats(stats) end + return mon,attached +end + +function Fields.publish(data) + if not require('src.core.GameVersion').isGen2() then return false end + for _,def in pairs(data and data.pokemon or {})do + if type(def)=='table' and type(def.baseStats)=='table' then + Fields.stats(def.baseStats) + end + end + return true +end + +return Fields diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 2ed5e2137..aec475c20 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -242,6 +242,9 @@ function Pokemon.new(data, species, level, rng, form) -- nil and this returns immediately, leaving the cartridge's own 1/8192 -- exactly as it was. Pokemon.rollShiny(data, nil, mon, rng) + if require('src.core.GameVersion').isGen2() then + require('src.pokemon.Gen2MonFields').bind(data,mon) + end return mon end diff --git a/src/pokemon/Stats.lua b/src/pokemon/Stats.lua index 25dd213b1..74cad7f0a 100644 --- a/src/pokemon/Stats.lua +++ b/src/pokemon/Stats.lua @@ -146,6 +146,7 @@ function Stats.calc(speciesDef, level, dvs, statExp, evs, nature) out.spdef = calcOne(base.spdef, dvs.special or 0, statExp.special, level, false) out.special = out.spatk + require('src.pokemon.Gen2MonFields').stats(out) end return out end @@ -182,6 +183,9 @@ function Stats.ensure(speciesDef, mon) mon.statExp) mon.stats.spatk, mon.stats.spdef = full.spatk, full.spdef end + if base.spatk and base.spdef then + require('src.pokemon.Gen2MonFields').stats(mon.stats) + end return mon end if Stats.isGen3(speciesDef) then diff --git a/tests/engine/gen2_mon_fields_data.lua b/tests/engine/gen2_mon_fields_data.lua new file mode 100644 index 000000000..e3b9e4154 --- /dev/null +++ b/tests/engine/gen2_mon_fields_data.lua @@ -0,0 +1,109 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +T.eq(_G.love,nil,'field views need no rendering runtime') +for _,name in ipairs{'src.battle.BattleState','src.pokemon.Sprites','src.render.Assets'}do + package.preload[name]=function()error('field view imported presentation: '..name)end +end +local ok,Fields=pcall(require,'src.pokemon.Gen2MonFields') +T.check(ok and type(Fields.bind)=='function','native GSC source fields exist') +if not ok then T.finish('native GSC field views are absent') end +local Stats=require('src.pokemon.Stats') +local Version=require('src.core.GameVersion') +local initial=Version.get() +for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + Version.set(edition) + local def={name='SOURCE NAME',types={'FIRE'},baseStats={hp=39,attack=52,defense=43, + speed=65,special=60,spatk=60,spdef=50}} + local data={pokemon={QA_MON=def}} + local stats=Stats.calc(def,15,{attack=14,defense=10,speed=10,special=10,hp=0}) + local mon={species='QA_MON',stats=stats,hp=11,moves={{id='QA_MOVE',pp=5}}} + local nativeCount=0;for _ in pairs(stats)do nativeCount=nativeCount+1 end + local same,attached=Fields.bind(data,mon) + T.eq(same,mon,'source view retains the actual Pokemon') + T.check(attached,'ordinary native Pokemon accepts source fields') + T.eq(mon.maxHp,stats.hp,'maximum HP is read from the actual native stat block') + T.eq(mon.name,def.name,'species display name comes from native registry') + T.eq(mon.types,def.types,'types retain the live registry table') + T.eq(stats.specialAttack,stats.spatk,'Special Attack reads native spelling') + T.eq(stats.specialDefense,stats.spdef,'Special Defense reads native spelling') + stats.specialAttack=77;T.eq(stats.spatk,77,'source stat write changes actual native stat') + stats.spdef=63;T.eq(stats.specialDefense,63,'native stat write remains visible') + mon.maxHp=80;T.eq(stats.hp,80,'source maximum write updates actual native maximum') + T.eq(mon.hp,11,'maximum write does not heal the current HP') + T.eq(rawget(mon,'maxHp'),nil,'no duplicated HP maximum is serialized') + T.eq(rawget(mon,'name'),nil,'no duplicated species display copy is serialized') + T.eq(rawget(mon,'types'),nil,'no duplicated type list is serialized') + T.eq(rawget(stats,'specialAttack'),nil,'no duplicated Special Attack is serialized') + T.eq(rawget(stats,'specialDefense'),nil,'no duplicated Special Defense is serialized') + local count=0;for _ in pairs(stats)do count=count+1 end + T.eq(count,nativeCount,'iteration keeps exactly the native stored columns') + local meta=getmetatable(mon);Fields.bind(data,mon) + T.eq(getmetatable(mon),meta,'same-owner binding is stable') + def.name='EDITED';T.eq(mon.name,'EDITED','live registry edits cannot leave a stale name') + data.pokemon.QA_MON={name='REPLACED',types={'WATER'}} + T.eq(mon.name,'REPLACED','full native species replacement is observed') + T.eq(mon.types,data.pokemon.QA_MON.types,'type replacement retains live identity') + local other={pokemon={QA_MON={name='OTHER OWNER',types={'GRASS'}}}} + local second={species='QA_MON',stats={hp=30,spatk=1,spdef=2}} + Fields.bind(data,second);Fields.bind(other,mon) + T.eq(mon.name,'OTHER OWNER','rebinding follows the new native data owner') + T.eq(second.name,'REPLACED','rebinding one mon cannot retarget another') + mon.stats=Stats.calc(def,20,{attack=1,defense=2,speed=3,special=4,hp=5}) + T.eq(mon.maxHp,mon.stats.hp,'native stat-block replacement updates maximum immediately') + T.eq(mon.stats.specialAttack,mon.stats.spatk,'native recalculation retains source stat spelling') + Fields.publish(other) + other.pokemon.QA_MON.baseStats={spatk=51,spdef=52};Fields.publish(other) + T.eq(other.pokemon.QA_MON.baseStats.specialAttack,51,'species base-stat aliases are published') + other.pokemon.QA_MON.baseStats.specialDefense=53 + T.eq(other.pokemon.QA_MON.baseStats.spdef,53,'base-stat edits reach native column') +end + +-- Existing custom behavior remains active. Plain native assignments bypass +-- __newindex exactly when the real canonical key is already stored. +local writes=0 +local custom={__index=function(_,key)if key=='legacy'then return 42 end end, + __newindex=function(t,key,value)writes=writes+1;rawset(t,key,value)end, + __len=function()return 9 end} +local stats=setmetatable({hp=20,spatk=12,spdef=13},custom) +Fields.stats(stats) +T.eq(stats.legacy,42,'old stat lookup survives') +T.eq(getmetatable(stats).__len,custom.__len,'unrelated metamethod survives') +stats.specialAttack=14;T.eq(stats.spatk,14);T.eq(writes,0,'stored native key bypasses legacy writer') +stats.extra=15;T.eq(writes,1,'unknown field retains existing writer') +local mon=setmetatable({species='QA_MON',stats=stats},custom) +Fields.bind({pokemon={QA_MON={name='VISIBLE'}}},mon) +T.eq(mon.name,'VISIBLE');T.eq(mon.legacy,42,'old Pokemon lookup survives') +mon.extra=1;T.eq(writes,2,'unknown Pokemon writes keep custom behavior') +local protected=setmetatable({stats={hp=2}},{__metatable='owned'}) +local _,bound=Fields.bind({},protected) +T.eq(bound,false,'protected custom owner is not replaced') +T.eq(getmetatable(protected),'owned','protected metatable remains intact') +local explicit={name='AUTHORED',maxHp=7,types={'CUSTOM'},stats={hp=8}} +Fields.bind({pokemon={}},explicit) +T.eq(explicit.name,'AUTHORED');T.eq(explicit.maxHp,7,'explicit authored stored fields retain precedence') +T.eq(explicit.types[1],'CUSTOM') +local unowned={species='QA_MON',stats={hp=3}} +Fields.bind(nil,unowned) +T.eq(unowned.name,nil,'unbound record has no guessed source species name') +local rebound={species='QA_MON',stats={hp=4}} +Fields.bind({pokemon={QA_MON={name='REAL OWNER'}}},rebound) +T.eq(rebound.name,'REAL OWNER','a real-owner cache miss cannot select unowned metadata') + +-- LuaJIT weak-key/value semantics: metadata may retain its dataset, but no +-- global weak-key/value cycle may keep an abandoned model/registry alive. +local weak=setmetatable({},{__mode='v'}) +do + local data={pokemon={QA_MON={name='TEMP'}}} + local mon={species='QA_MON',stats={hp=1,spatk=2,spdef=3}} + data.ownedMon=mon + Fields.bind(data,mon) + weak[1],weak[2],weak[3]=mon,data,getmetatable(mon) +end +for _=1,4 do collectgarbage('collect')end +T.eq(weak[1],nil,'abandoned Pokemon is collectible') +T.eq(weak[2],nil,'cyclic abandoned dataset is collectible') +T.eq(weak[3],nil,'abandoned field metadata is collectible') +Version.set(initial) +T.eq(_G.love,nil,'field views never install a fake rendering runtime') +T.finish('native GSC source fields, live ownership, custom tables and collection without LÖVE') diff --git a/tests/engine/gen2_mon_fields_native.lua b/tests/engine/gen2_mon_fields_native.lua new file mode 100644 index 000000000..f341b8f6d --- /dev/null +++ b/tests/engine/gen2_mon_fields_native.lua @@ -0,0 +1,112 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local ok,Fields=pcall(require,'src.pokemon.Gen2MonFields') +T.check(ok and type(Fields.bind)=='function','native GSC source fields exist') +local Version=require('src.core.GameVersion') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Stats=require('src.pokemon.Stats') +local Native=require('src.battle.BattleState') +local initial=Version.get() +if not ok then + Version.set('crystal') + local data=T.fixtures.fresh();data.constants.gen=2 + data.pokemon.FIXMON_A.baseStats.spatk=60;data.pokemon.FIXMON_A.baseStats.spdef=50 + local mon=Pokemon.new(data,'FIXMON_A',25) + T.eq(mon.maxHp,mon.stats.hp,'actual native birth supplies source maximum HP') + T.eq(mon.name,data.pokemon.FIXMON_A.name,'actual native birth supplies source species name') + T.eq(mon.types,data.pokemon.FIXMON_A.types,'actual native birth supplies source type table') + T.eq(mon.stats.specialAttack,mon.stats.spatk,'actual native birth supplies source Special Attack') + T.eq(mon.stats.specialDefense,mon.stats.spdef,'actual native birth supplies source Special Defense') + Version.set(initial) + T.finish('actual native birth source fields are absent') +end +local function clone(value) + if type(value)~='table'then return value end + local out={};for k,v in pairs(value)do out[k]=clone(v)end;return out +end +for _,edition in ipairs(Version.ORDER)do + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=Version.generation() + if Version.isGen2()then + for _,def in pairs(data.pokemon)do + def.baseStats.spatk,def.baseStats.spdef=60,50 + end + end + local save=Save.newGame() + local mon=Pokemon.new(data,'FIXMON_A',25) + save.party={mon} + if Version.isGen2()then + T.eq(mon.name,data.pokemon.FIXMON_A.name,edition..' actual native birth publishes source name') + T.eq(mon.maxHp,mon.stats.hp,'actual native birth publishes source maximum') + T.eq(mon.stats.specialAttack,mon.stats.spatk,'actual native birth publishes Special Attack') + T.eq(mon.stats.specialDefense,mon.stats.spdef,'actual native birth publishes Special Defense') + T.eq(mon.types,data.pokemon.FIXMON_A.types,'source types are actual native registry row') + local before=Save.encode(save) + local plain=clone(save) + T.eq(Save.encode(plain),before,'actual native save codec stores no derived alias/helper fields') + T.eq(rawget(mon,'maxHp'),nil);T.eq(rawget(mon.stats,'specialAttack'),nil) + local sourceName=mon.name + data.pokemon.FIXMON_A.name='LIVE EDIT' + T.eq(mon.name,'LIVE EDIT','native registry name edit is visible immediately') + data.pokemon.FIXMON_A.name=sourceName + local b=Native.makeBattler(data,mon,true,save) + T.eq(b.mon,mon,'actual native battler retains original Pokemon identity') + T.eq(b.curStats,mon.stats,'actual native battler retains original stat identity') + mon.stats.specialAttack=97 + T.eq(b.curStats.spatk,97,'source Special Attack writes reach actual native combat input') + local statBlock=mon.stats + mon.stats=Stats.calc(data.pokemon.FIXMON_A,mon.level,mon.dvs,mon.statExp) + T.neq(mon.stats,statBlock,'real native recalculation replaced the stat block') + T.eq(mon.maxHp,mon.stats.hp,'source maximum follows real native recalculation') + T.eq(mon.stats.specialDefense,mon.stats.spdef,'recalculation retains source special fields') + -- Import/semantic load removes all metatables. Real save validation must + -- hydrate the same decoded party member before normal menus/battles. + local loaded=assert(Save.decode(Save.encode(save))) + local original=loaded.party[1] + T.eq(original.maxHp,nil,'decoded native bytes contain no helper maximum') + Save.validate(loaded,data) + T.eq(loaded.party[1],original,'real validation retains decoded party identity') + T.eq(original.name,data.pokemon.FIXMON_A.name,'real validation restores native source name') + T.eq(original.maxHp,original.stats.hp,'real validation restores native maximum view') + T.eq(original.stats.specialAttack,original.stats.spatk,'real validation restores source special names') + local game={data=data,save=loaded,stack={top=function()end}} + local screen=Native.newWild(game,'FIXMON_B',15) + T.eq(screen.player.mon,original,'actual older native controller uses original validated Pokemon') + T.eq(screen.player.mon.maxHp,screen.player.mon.stats.hp,'actual older native controller reports combat maximum') + T.eq(screen.enemy.mon.name,data.pokemon.FIXMON_B.name,'live wild opponent has native source name') + T.eq(screen.enemy.mon.stats.specialDefense,screen.enemy.curStats.spdef, + 'live source model and actual enemy combat statistics agree') + -- A box mon can have no stat block until the native status/withdraw path. + local boxed=clone(original);boxed.stats=nil + Fields.bind(data,boxed);Stats.ensure(data.pokemon[boxed.species],boxed) + T.eq(boxed.maxHp,boxed.stats.hp,'box stat creation supplies the source maximum') + T.eq(boxed.stats.specialAttack,boxed.stats.spatk,'box stat creation supplies source split stats') + else + T.eq(rawget(mon,'maxHp'),nil,edition..' native serialization remains unchanged') + T.eq(getmetatable(mon),nil,edition..' other generation Pokemon is not decorated') + end +end + +-- The actual SDK/Loader publishes source base-stat spellings before a mod +-- reads its host data; the real registry writer retains them after edits. +Version.set('crystal') +local data=T.fixtures.fresh();data.constants.gen=2 +data.pokemon.FIXMON_A.baseStats.spatk=61;data.pokemon.FIXMON_A.baseStats.spdef=62 +local run=T.sdk.loadMods({'mods/field_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/field_probe/manifest.json']='{"id":"field_probe","name":"Field probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/field_probe/main.lua']=[[ + local mod=... + local Mon=require('src.battle.gen2.Mon') + mod.content.pokemon:patch('FIXMON_A',{baseStats={spatk=91}}) + mod.exports.make=function(data)return Mon.new(data,'FIXMON_A',15,function(a)return a end)end + ]], +})}) +T.eq(#run.errors,0,'actual SDK source Mon consumer loads') +T.eq(data.pokemon.FIXMON_A.baseStats.specialAttack,91,'actual older Loader publishes source base stats after a real native registry fold') +local mon=run.loader.exports.field_probe.make(data) +T.eq(mon.maxHp,mon.stats.hp,'actual source Mon constructor returns native maximum view') +T.eq(rawget(data.pokemon.FIXMON_A.baseStats,'specialAttack'),nil,'registry view stores no duplicate column') +run.release();Version.set(initial) +T.finish('source GSC fields through native birth, save codec, validation, combat, boxes and SDK registries') From c0ea73de33854ef6dc17862c1554809ed1b4d7f3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:00:28 +0100 Subject: [PATCH 177/187] Document older-host native field integration and separate constructor gaps --- docs/native-gen2-mon-fields.md | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/native-gen2-mon-fields.md diff --git a/docs/native-gen2-mon-fields.md b/docs/native-gen2-mon-fields.md new file mode 100644 index 000000000..a51b78cbd --- /dev/null +++ b/docs/native-gen2-mon-fields.md @@ -0,0 +1,60 @@ +# Live native GSC Pokemon fields + +Source GSC consumers read `mon.maxHp`, `mon.name`, `mon.types`, and +`stats.specialAttack`/`stats.specialDefense`. Native Pokemon store their +maximum in `stats.hp`, look up species display/types from the data registry, +and use `spatk`/`spdef`. A real native birth on preceding source `a6eb0c05` +fails all five source-field checks; the implementation module is absent too. +Unchanged menu, healing and capture consumers can consequently read missing +maximums or special statistics despite the native combat values existing. + +`Gen2MonFields` supplies those spellings on the actual native tables. +Reads follow native stat-block replacement and live registry changes; +source stat/maximum writes reach their native canonical fields. Construction, +save validation, the native battler data factory and identity refresh bind +the original Pokemon. Native GSC stat calculation/rehydration publish split-stat aliases. This +older host republishes species base-stat aliases after its real generic +registry fold, without adding the newer fork's species-name writer. Data seeding and actual +mod loading also expose the base-stat spellings used by source consumers. +There is no parallel Pokemon, duplicated HP counter or per-frame polling. + +Aliases live on metatables, without adding helper keys to serialized records. +Explicit authored stored fields retain their existing precedence. Existing +custom metamethods are preserved; protected custom metatables are not +replaced and may therefore decline the additional fields. Live Pokemon retain +their data owner; abandoned Pokemon/data cycles remain collectible on LuaJIT. +The helper does not allocate graphics or install a fake LÖVE runtime. + +Newer fork selected qualification on corrected source `2a000267` (not an +independent qualification of this older offered host): + +- 156 pure-data checks with no LÖVE, including live owners, canonical writes, + replaced registries/stat blocks, custom/protected tables and cycle collection. +- 162 actual native checks across the registered editions: birth, real save + encoding/decoding/validation, native battlers and source model, box stats, + actual SDK loading and native registry writes. Non-GSC Pokemon are excluded. +- Existing data construction 691, native construction/art hydration 972, + identity ownership 142, species identity 145 and save-summary 50 checks pass. + +The first implementation lost custom metamethods on a cache miss; four pure +checks caught it. The corrected cache selection passes all checks. The same +native probe on the preceding committed engine fails six checks, including +the five observed field gaps. Complete current-tree, unchanged-mod GPU/menu +and final release qualification remain open. This does not implement the +still-missing standalone source battle constructor or turn/event executor. + +No mod, protected Gen1 installation, ROM original or player profile is edited. +Existing engine authors' rights remain effective; native field integration +and tests by Roxas2712 are subject to `LICENSE-ROXAS2712.md`. + +This older adaptation uses its actual `BattleState.makeBattler`, controller +and original RNG constructor signature. It does not fabricate the newer +fork's extracted Battler, live source model, identity refresh or source +options-record constructor, which are separate offered work. Its own native +probe follows the actual older SDK loader/registry fold and is pending. +It is stacked on upstream PR398 and its existing prerequisites. + +Independently owned integration/tests by Roxas2712 are offered under +, +including the specific Cedric / UNDERdecoded permission with visible credit. +Existing engine authors' rights, notices and earlier grants remain effective. From cd65abe1253657e1c2eb630d92386266a86560ce Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:08:43 +0100 Subject: [PATCH 178/187] Validate real older-host split-stat patches before native registry folds --- src/mods/Schemas.lua | 5 ++++- tests/engine/gen2_mon_fields_native.lua | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index d9e994c1a..5035a8f79 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -416,7 +416,10 @@ R.pokemon = { types = f.list(f.id("type_chart")), baseStats = 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) }, + special = f.int(1, 255), + -- Native GSC/GBA split stats are valid registry fields. + spatk = f.opt(f.int(1, 255)), + spdef = f.opt(f.int(1, 255)) }, catchRate = f.int(0, 255), baseExp = f.int(0, 255), level1Moves = f.list(f.id("moves")), growthRate = f.id("growth_rates"), diff --git a/tests/engine/gen2_mon_fields_native.lua b/tests/engine/gen2_mon_fields_native.lua index f341b8f6d..ae4e26886 100644 --- a/tests/engine/gen2_mon_fields_native.lua +++ b/tests/engine/gen2_mon_fields_native.lua @@ -9,6 +9,20 @@ local Save=require('src.core.SaveData') local Stats=require('src.pokemon.Stats') local Native=require('src.battle.BattleState') local initial=Version.get() +-- Exercise the real strict nested schema, not a direct unchecked Registry +-- call. Both native split stats retain the same 1..255 bounds as other stats. +local Schemas=require('src.mods.Schemas') +for _,key in ipairs({'spatk','spdef'})do + for _,value in ipairs({1,91,255})do + T.check(Schemas.check(Schemas.REGISTRIES.pokemon,'pokemon','FIXMON_A', + {baseStats={[key]=value}},'patch'),'native split-stat patch accepted') + end + for _,value in ipairs({0,256,1.5,'91'})do + local accepted=Schemas.check(Schemas.REGISTRIES.pokemon,'pokemon','FIXMON_A', + {baseStats={[key]=value}},'patch') + T.eq(accepted,false,'invalid native split-stat patch rejected') + end +end if not ok then Version.set('crystal') local data=T.fixtures.fresh();data.constants.gen=2 @@ -99,12 +113,13 @@ local run=T.sdk.loadMods({'mods/field_probe'},{data=data,fs=T.sdk.memfs({ ['mods/field_probe/main.lua']=[[ local mod=... local Mon=require('src.battle.gen2.Mon') - mod.content.pokemon:patch('FIXMON_A',{baseStats={spatk=91}}) + mod.content.pokemon:patch('FIXMON_A',{baseStats={spatk=91,spdef=92}}) mod.exports.make=function(data)return Mon.new(data,'FIXMON_A',15,function(a)return a end)end ]], })}) T.eq(#run.errors,0,'actual SDK source Mon consumer loads') T.eq(data.pokemon.FIXMON_A.baseStats.specialAttack,91,'actual older Loader publishes source base stats after a real native registry fold') +T.eq(data.pokemon.FIXMON_A.baseStats.specialDefense,92,'actual older Loader retains both native split-stat patches') local mon=run.loader.exports.field_probe.make(data) T.eq(mon.maxHp,mon.stats.hp,'actual source Mon constructor returns native maximum view') T.eq(rawget(data.pokemon.FIXMON_A.baseStats,'specialAttack'),nil,'registry view stores no duplicate column') From 98b620d114e5220ab7e7af25e47f16533840fc8f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:40:02 +0100 Subject: [PATCH 179/187] Authenticate raw native field wrappers while retaining custom-owner refusal --- src/pokemon/Gen2MonFields.lua | 27 ++++++++++++++++++++++++-- tests/engine/gen2_mon_fields_data.lua | 28 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/pokemon/Gen2MonFields.lua b/src/pokemon/Gen2MonFields.lua index f4f46eaf0..270c5360c 100644 --- a/src/pokemon/Gen2MonFields.lua +++ b/src/pokemon/Gen2MonFields.lua @@ -3,7 +3,7 @@ -- aliases are never duplicated into saves and keep observing native writes. local Fields = {} local installed = setmetatable({}, {__mode='k'}) -local dataKey, baseKey = {}, {} +local dataKey, baseKey, signatureKey = {}, {}, {} -- Both entries are weak: a value retaining its dataset must not root that -- weak key on LuaJIT. Live Pokemon retain their own dataset via the metatable. local monPlain = setmetatable({}, {__mode='kv'}) @@ -52,7 +52,8 @@ local function metatableFor(kind, previous, data) if found then return found end local meta={} for key,value in pairs(previous or {})do meta[key]=value end - if kind=='mon' then meta[dataKey],meta[baseKey]=data,previous or false end + meta[baseKey]=previous or false + if kind=='mon' then meta[dataKey]=data end local oldIndex,oldWrite=meta.__index,meta.__newindex meta.__index=function(object,key) local value=getter(kind,object,key,oldIndex,data) @@ -67,6 +68,11 @@ local function metatableFor(kind, previous, data) end return write(oldWrite,object,key,value) end + -- Keep authentication with the live metatable, not in a global value + -- that could retain its dataset/Pokemon cycle on LuaJIT. + local signature={} + for key,value in next,meta do signature[key]=value end + meta[signatureKey]=signature installed[meta]=kind if kind=='mon' then if not previous then @@ -90,6 +96,23 @@ local function attach(object, kind, data) return true end +-- The checkpoint codecs store only raw record entries, never these +-- derived aliases or their data-owner closure. Authenticate our unchanged +-- plain wrappers; custom, protected, copied or edited metatables stay opaque. +function Fields.plainKind(object) + if type(object)~='table' then return nil end + local meta=getmetatable(object) + local kind=installed[meta] + if not kind or getmetatable(meta)~=nil or rawget(meta,baseKey)~=false then return nil end + local signature=rawget(meta,signatureKey) + if type(signature)~='table' then return nil end + for key,value in next,signature do if rawget(meta,key)~=value then return nil end end + for key,value in next,meta do + if key~=signatureKey and rawget(signature,key)~=value then return nil end + end + return kind +end + function Fields.stats(stats) attach(stats,'stats') return stats diff --git a/tests/engine/gen2_mon_fields_data.lua b/tests/engine/gen2_mon_fields_data.lua index e3b9e4154..3fe6715f7 100644 --- a/tests/engine/gen2_mon_fields_data.lua +++ b/tests/engine/gen2_mon_fields_data.lua @@ -59,6 +59,34 @@ for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do T.eq(other.pokemon.QA_MON.baseStats.spdef,53,'base-stat edits reach native column') end +-- Semantic snapshots may strip only the exact native wrapper. A cloned, +-- customized or modified metatable is still an opaque external owner. +local native={species='QA_MON',stats={hp=9,spatk=2,spdef=3}} +Fields.bind({pokemon={QA_MON={name='NATIVE'}}},native) +T.eq(Fields.plainKind(native),'mon','native Pokemon wrapper authenticated for raw serialization') +T.eq(Fields.plainKind(native.stats),'stats','native stats wrapper authenticated for raw serialization') +T.eq(Fields.plainKind({}),nil,'ordinary data does not claim native wrapper ownership') +T.eq(Fields.plainKind(1),nil,'scalar has no native wrapper') +local nativeMeta=getmetatable(native) +local impostor={};local copied={};for k,v in pairs(nativeMeta)do copied[k]=v end +setmetatable(impostor,copied) +T.eq(Fields.plainKind(impostor),nil,'copying a metatable does not authenticate it') +local originalIndex=nativeMeta.__index +nativeMeta.__index=function()return 'opaque'end +T.eq(Fields.plainKind(native),nil,'modified native metatable is rejected') +nativeMeta.__index=originalIndex +T.eq(Fields.plainKind(native),'mon','restoring exact native wrapper restores authentication') +nativeMeta.external=true +T.eq(Fields.plainKind(native),nil,'additional metatable key is opaque') +nativeMeta.external=nil +T.eq(Fields.plainKind(native),'mon','exact original wrapper remains available') +local customNative=setmetatable({stats={hp=3}},{__index=function()return 1 end}) +Fields.bind({},customNative) +T.eq(Fields.plainKind(customNative),nil,'custom source fields cannot weaken plain checkpoint admission') +local customStats=setmetatable({hp=2},{__len=function()return 8 end}) +Fields.stats(customStats) +T.eq(Fields.plainKind(customStats),nil,'custom stat metadata remains opaque') + -- Existing custom behavior remains active. Plain native assignments bypass -- __newindex exactly when the real canonical key is already stored. local writes=0 From 6985a377e9b888405ccc361eefc34200dcd2c9fd Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:42:55 +0100 Subject: [PATCH 180/187] Provide native-slot field rebinding without inspecting mod storage --- src/pokemon/Gen2MonFields.lua | 28 +++++++++++++++++++++++++ tests/engine/gen2_mon_fields_data.lua | 18 ++++++++++++++++ tests/engine/gen2_mon_fields_native.lua | 2 +- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/pokemon/Gen2MonFields.lua b/src/pokemon/Gen2MonFields.lua index 270c5360c..1a56757d4 100644 --- a/src/pokemon/Gen2MonFields.lua +++ b/src/pokemon/Gen2MonFields.lua @@ -126,6 +126,34 @@ function Fields.bind(data, mon) return mon,attached end +-- Semantic world reconstruction restores raw saved records after map +-- entry. Bind only real native Pokemon slots; do not walk arbitrary modData, +-- repair progress, roll identity, calculate stats, emit events or write files. +function Fields.bindSave(data, save) + if not require('src.core.GameVersion').isGen2() or type(save)~='table' then return save end + local function mon(value) + if type(value)=='table' and data and data.pokemon and data.pokemon[value.species] then + Fields.bind(data,value) + end + end + for _,value in pairs(save.party or {})do mon(value)end + for _,box in pairs(save.boxes or {})do + if type(box)=='table' then for _,value in pairs(box.mons or box)do mon(value)end end + end + local daycare=save.daycare + if type(daycare)=='table' then + mon(daycare.mon) + local breed=daycare.breed + if type(breed)=='table' then + for _,index in ipairs{1,2}do + local entry=breed[index];if type(entry)=='table' then mon(entry.mon)end + end + mon(breed.egg) + end + end + return save +end + function Fields.publish(data) if not require('src.core.GameVersion').isGen2() then return false end for _,def in pairs(data and data.pokemon or {})do diff --git a/tests/engine/gen2_mon_fields_data.lua b/tests/engine/gen2_mon_fields_data.lua index 3fe6715f7..59c66825e 100644 --- a/tests/engine/gen2_mon_fields_data.lua +++ b/tests/engine/gen2_mon_fields_data.lua @@ -87,6 +87,24 @@ local customStats=setmetatable({hp=2},{__len=function()return 8 end}) Fields.stats(customStats) T.eq(Fields.plainKind(customStats),nil,'custom stat metadata remains opaque') +local records={} +for i=1,8 do records[i]={species='QA_MON',stats={hp=i,spatk=2,spdef=3}}end +local restored={party={records[1]},boxes={{[3]=records[2]},{mons={[7]=records[3]}}}, + daycare={mon=records[4],breed={{mon=records[5]},{mon=records[6]},egg=records[7]}}, + modData={consumer=records[8]}} +local restoredData={pokemon={QA_MON={name='RECONSTRUCTED'}}} +Version.set('crystal') +T.eq(Fields.bindSave(restoredData,restored),restored,'semantic save binding retains actual save identity') +for i=1,7 do + T.eq(records[i].name,'RECONSTRUCTED','native saved Pokemon slot rebound') + T.eq(records[i].maxHp,i,'native saved stat block retained') +end +T.eq(getmetatable(records[8]),nil,'arbitrary mod storage is not traversed') +local foreign={party={{species='QA_MON'}}} +Version.set('red');Fields.bindSave(restoredData,foreign) +T.eq(getmetatable(foreign.party[1]),nil,'non-GSC semantic save is excluded') +Version.set('crystal') + -- Existing custom behavior remains active. Plain native assignments bypass -- __newindex exactly when the real canonical key is already stored. local writes=0 diff --git a/tests/engine/gen2_mon_fields_native.lua b/tests/engine/gen2_mon_fields_native.lua index ae4e26886..bf7cb1cef 100644 --- a/tests/engine/gen2_mon_fields_native.lua +++ b/tests/engine/gen2_mon_fields_native.lua @@ -20,7 +20,7 @@ for _,key in ipairs({'spatk','spdef'})do for _,value in ipairs({0,256,1.5,'91'})do local accepted=Schemas.check(Schemas.REGISTRIES.pokemon,'pokemon','FIXMON_A', {baseStats={[key]=value}},'patch') - T.eq(accepted,false,'invalid native split-stat patch rejected') + T.check(not accepted,'invalid native split-stat patch rejected') end end if not ok then From cfe4f4fd40b4b50ab0d016fb37e0883454376129 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:45:27 +0100 Subject: [PATCH 181/187] Record independent older-host live-field and strict SDK qualification --- docs/native-gen2-mon-fields.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/native-gen2-mon-fields.md b/docs/native-gen2-mon-fields.md index a51b78cbd..1fe84d3be 100644 --- a/docs/native-gen2-mon-fields.md +++ b/docs/native-gen2-mon-fields.md @@ -51,8 +51,20 @@ This older adaptation uses its actual `BattleState.makeBattler`, controller and original RNG constructor signature. It does not fabricate the newer fork's extracted Battler, live source model, identity refresh or source options-record constructor, which are separate offered work. Its own native -probe follows the actual older SDK loader/registry fold and is pending. -It is stacked on upstream PR398 and its existing prerequisites. +probe follows the actual older SDK loader/registry fold. Corrected source +`6985a377` independently passes184 pure-data and170 native checks across +its15 registered editions, plus4454 frame-query,116 chrome and1031 +screen-position regressions. The managed host is cleaned after exit. +Its initial independent run on `c0ea73de` passed the pure-data/frame/chrome/ +position checks but rejected the actual SDK `spatk` patch as an unknown +nested schema field. The schema now accepts native `spatk`/`spdef` with the +same1..255 integer bounds; invalid inputs remain rejected. The native +registry-fold test still patches both real columns and now passes. +The older host has no semantic-checkpoint subsystem: no newer checkpoint +facade or unqualified checkpoint integration is added to this offer. +The pure helper authenticates its own unchanged raw-record wrappers and +provides native-slot rebinding, with custom-owner refusal and no mod-storage +traversal. It is stacked on upstream PR398 and its existing prerequisites. Independently owned integration/tests by Roxas2712 are offered under , From 7782ccdc7de9df2d862eb1d3e36d2102f1f44f78 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:30:55 +0100 Subject: [PATCH 182/187] Expose GSC source stat refresh through the actual composed upstream owners --- docs/native-gen2-stat-refresh.md | 35 ++++++++++ src/battle/Battler.lua | 1 + src/compat/gen2/Mon.lua | 14 ++++ src/core/ModCompat.lua | 2 +- src/pokemon/Pokemon.lua | 40 +++++++++-- src/pokemon/Stats.lua | 65 ++++++++++++++---- src/ui/SummaryMenu.lua | 3 +- tests/engine/gen2_stat_refresh_data.lua | 80 ++++++++++++++++++++++ tests/engine/gen2_stat_refresh_native.lua | 82 +++++++++++++++++++++++ 9 files changed, 302 insertions(+), 20 deletions(-) create mode 100644 docs/native-gen2-stat-refresh.md create mode 100644 src/compat/gen2/Mon.lua create mode 100644 tests/engine/gen2_stat_refresh_data.lua create mode 100644 tests/engine/gen2_stat_refresh_native.lua diff --git a/docs/native-gen2-stat-refresh.md b/docs/native-gen2-stat-refresh.md new file mode 100644 index 000000000..5bba0e479 --- /dev/null +++ b/docs/native-gen2-stat-refresh.md @@ -0,0 +1,35 @@ +# Source GSC stat refresh on current upstream + +Prepared on actual original main `8d5b4387`, composing existing appearance +PR373, Unown PR307, battler/model foundation PR396 and field/viewport PR399. +The new combined older host is awaiting its own independent qualification. +No earlier parent receipt qualifies these newly composed bytes. + +The adapter adds stats, hpDV, refreshStats(mon,data) and the correctly +ordered syncIdentity(mon,data) over real native owners. Its older inherited +RNG constructor remains unchanged. Source options-record construction, the +remaining Mon API and standalone battle constructor/turn APIs remain open. +No nonexistent identity, Unown or battler facade is invented: their actual +offered implementations are composed first. + +One shared native GSC formula derives HP from the four genes and retains +legacy Special fallbacks. Native summary recalculates existing stats; +weak native ownership follows shared party-stat replacement and preserves +separate Transform/current overrides. Explicit later native IV/EV/nature +models are retained. Pokemon/HP are not duplicated, no extra clock runs. + +Prerequisite conflict resolution keeps current upstream move refusals in +the actual BattleCommands owner, both source-composition and output paths, +viewport/reset/cancellation services, display/shade palette separation, +canonical/numeric registry IDs, field binding and original author credits. +All of that composition still requires actual runtime/contract tests. + +Newer fork source273692e8 passes185360 pure-data and238 actual native checks, +plus summary/identity/field/battler/checkpoint regressions; its predecessor +fails the real source probes. These results do not qualify this older host, +GPU or a release. No mod or protected/player installation is edited. + +Integration/tests by Roxas2712 are offered under +https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md, +including the specific Cedric / UNDERdecoded permission with visible credit. +Existing engine authors' rights, notices and earlier grants remain effective. diff --git a/src/battle/Battler.lua b/src/battle/Battler.lua index 7ee20c755..f34d0f248 100644 --- a/src/battle/Battler.lua +++ b/src/battle/Battler.lua @@ -46,6 +46,7 @@ function Battler.new(data, mon, isPlayer, save, battle) curTypes = def.types, curMoves = mon.moves, } + if require('src.core.GameVersion').isGen2() then require('src.pokemon.Stats').trackBattler(battler)end return Gen2.bind(battler, battle) end diff --git a/src/compat/gen2/Mon.lua b/src/compat/gen2/Mon.lua new file mode 100644 index 000000000..4cd35118d --- /dev/null +++ b/src/compat/gen2/Mon.lua @@ -0,0 +1,14 @@ +-- Copyright (c) 2026 Roxas2712. See the offered contribution licence: +-- https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Source stat/signature methods over the actual native Pokemon owner. +-- The inherited older RNG constructor keeps its existing signature; +-- source options-record construction remains a separate capability. +local Native=require('src.pokemon.Pokemon') +local Mon={} +function Mon.hpDV(dvs)return require('src.pokemon.Stats').hpDV(dvs)end +function Mon.stats(base,dvs,level,statExp) + return require('src.pokemon.Stats').calcGen2(base,dvs,level,statExp) +end +function Mon.syncIdentity(mon,data)return Native.syncIdentity(data,mon)end +function Mon.refreshStats(mon,data)return Native.refreshStats(data,mon)end +return setmetatable(Mon,{__index=Native}) diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 230215073..80f93e294 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -73,7 +73,7 @@ ModCompat.ALIASES = { ["src.battle.gen2.Catching"] = "src.battle.Catching", ["src.battle.gen2.Damage"] = "src.battle.Damage", ["src.battle.gen2.Effects"] = "src.battle.MoveEffects", - ["src.battle.gen2.Mon"] = "src.pokemon.Pokemon", + ["src.battle.gen2.Mon"] = "src.compat.gen2.Mon", ["src.ui.gen2.BattleState"] = "src.battle.BattleState", ["src.ui.gen2.Chrome"] = "src.ui.Gen2Chrome", diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index eb7ede70f..2c5453a74 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -286,14 +286,46 @@ end -- Gen 2 refreshes identity at its summary/stat-refresh boundary. A true -- shiny stays authoritative; a cached false and gender are read again. function Pokemon.syncIdentity(data, mon) - if require('src.core.GameVersion').isGen2() then require('src.pokemon.Gen2MonFields').bind(data,mon)end - if type(mon) ~= "table" or type(mon.dvs)~='table' - or require("src.core.GameVersion").generation() ~= 2 then return mon end - local Rolls = require("src.pokemon.IdentityRolls") + local Version=require('src.core.GameVersion') + if Version.generation()~=2 or type(mon)~='table' then return mon end + require('src.pokemon.Gen2MonFields').bind(data,mon) local def = data and data.pokemon and data.pokemon[mon.species] if not def then return mon end + -- Derived native names/types stay unstored. An older explicitly stored + -- display copy must be refreshed too, as on the source summary boundary. + if rawget(mon,'name')~=nil then mon.name=def.name or mon.species end + if def.types and rawget(mon,'types')~=nil then mon.types=def.types end + if type(mon.dvs)~='table' then return mon end + local Rolls = require('src.pokemon.IdentityRolls') mon.gender = Rolls.gender(data, mon, 2, mon.level) mon.shiny = mon.shiny or Rolls.gen2Shiny(mon, def) + local Unown=require('src.pokemon.Gen2Unown') + if Unown.supported() then + mon.unownLetter=def==Unown.definition(data) and Unown.letterFromDVs(mon.dvs) or nil + end + return mon +end + +-- GSC summary/stat refresh recalculates an existing block; Stats.ensure is +-- deliberately different (it preserves an already loaded party stat block). +-- No current-HP refill, identity re-roll at birth or second Pokemon is made. +function Pokemon.refreshStats(data, mon) + if require('src.core.GameVersion').generation()~=2 or type(mon)~='table' then return mon end + local def=data and data.pokemon and data.pokemon[mon.species] + if not(def and def.baseStats) then return mon end + Pokemon.syncIdentity(data,mon) + local calculated + if Stats.isGen3(def) then + -- A GSC-hosted extension can explicitly use the native later stat model. + -- Preserve that structural owner, its six IVs, EVs and nature too. + calculated=Stats.calc(def,mon.level or 1,mon.ivs or mon.dvs,mon.statExp,mon.evs,mon.nature) + else + calculated=Stats.calcGen2(def.baseStats,mon.dvs,mon.level or 1,mon.statExp) + end + Stats.replace(mon,calculated) + require('src.pokemon.Gen2MonFields').bind(data,mon) + mon.maxHp=mon.stats.hp + if mon.hp==nil or mon.hp>mon.stats.hp then mon.hp=mon.stats.hp end return mon end diff --git a/src/pokemon/Stats.lua b/src/pokemon/Stats.lua index 74cad7f0a..d1a050f0e 100644 --- a/src/pokemon/Stats.lua +++ b/src/pokemon/Stats.lua @@ -4,6 +4,22 @@ -- The HP DV is derived from the low bits of the other four DVs. local Stats = {} +-- Live native wrappers share party stats until Transform replaces their own +-- block. Weak keys retain neither finished battles nor Pokemon datasets. +local statOwners=setmetatable({}, {__mode='k'}) +function Stats.trackBattler(battler) + statOwners[battler]=true + return battler +end +function Stats.replace(mon, stats) + local previous=mon.stats + mon.stats=stats + for owner in pairs(statOwners)do + if owner.mon==mon and owner.curStats==previous then owner.curStats=stats end + end + return stats +end + local ORDER = { "hp", "attack", "defense", "speed", "special" } Stats.ORDER = ORDER @@ -50,6 +66,15 @@ function Stats.isGen3(speciesDef) and speciesDef.baseStats.spdef ~= nil end +-- GSC stores only the four genes. A stale saved HP DV must not override +-- their low bits when a mod/editor changes a gene and refreshes the stats. +function Stats.hpDV(dvs, special) + dvs=dvs or {} + if special==nil then special=dvs.special end + return ((dvs.attack or 0)%2)*8+((dvs.defense or 0)%2)*4 + +((dvs.speed or 0)%2)*2+(special or 0)%2 +end + function Stats.randomDVs(rng) rng = rng or love.math.random local dvs = { @@ -58,8 +83,7 @@ function Stats.randomDVs(rng) speed = rng(0, 15), special = rng(0, 15), } - dvs.hp = (dvs.attack % 2) * 8 + (dvs.defense % 2) * 4 + - (dvs.speed % 2) * 2 + (dvs.special % 2) + dvs.hp = Stats.hpDV(dvs) return dvs end @@ -123,6 +147,28 @@ function Stats.calcGen3(speciesDef, level, ivs, evs, nature) return out end +-- One actual GSC formula for native recalculation and the public source +-- Mon.stats API. Accept source split names at this input boundary, but keep +-- native canonical output fields and live source aliases on that same table. +function Stats.calcGen2(base, dvs, level, statExp) + base,dvs,statExp=base or {},dvs or {},statExp or {} + local special=dvs.special + if special==nil then special=dvs.specialAttack or dvs.specialDefense end + local function value(stat,dv,effort,hp) + return calcOne(stat or 1,dv or 0,math.max(0,effort or 0),level,hp) + end + local out={ + hp=value(base.hp,Stats.hpDV(dvs,special),statExp.hp,true), + attack=value(base.attack,dvs.attack,statExp.attack), + defense=value(base.defense,dvs.defense,statExp.defense), + speed=value(base.speed,dvs.speed,statExp.speed), + spatk=value(base.spatk or base.specialAttack,special,statExp.special or statExp.specialAttack), + spdef=value(base.spdef or base.specialDefense,special,statExp.special or statExp.specialDefense), + } + out.special=out.spatk + return require('src.pokemon.Gen2MonFields').stats(out) +end + function Stats.calc(speciesDef, level, dvs, statExp, evs, nature) if Stats.isGen3(speciesDef) then -- a Gen 3 mon carries ivs/evs; the dvs/statExp parameters are what the @@ -131,23 +177,14 @@ function Stats.calc(speciesDef, level, dvs, statExp, evs, nature) end statExp = statExp or {} local base = speciesDef.baseStats + if (base.spatk and base.spdef) or (base.specialAttack and base.specialDefense) then + return Stats.calcGen2(base,dvs,level,statExp) + end local out = {} for _, key in ipairs(ORDER) do out[key] = calcOne(base[key], dvs[key] or 0, statExp[key], level, key == "hp") end - -- Gen 2 split Special into two base stats but kept ONE Special DV and - -- ONE Special stat-exp slot (CalcMonStats runs the same CalcStat over - -- six base values, reading wDVs' low nibble for both). A Gen 1 species - -- record has no spatk/spdef, so it keeps the single `special`. - if base.spatk and base.spdef then - out.spatk = calcOne(base.spatk, dvs.special or 0, - statExp.special, level, false) - out.spdef = calcOne(base.spdef, dvs.special or 0, - statExp.special, level, false) - out.special = out.spatk - require('src.pokemon.Gen2MonFields').stats(out) - end return out end diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index 090401b37..75194e8ab 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -147,7 +147,8 @@ function SummaryMenu.new(game, mon, opts) -- which crashed the HP bar draw below (#233). Redundant once -- SaveData.validate has run over a loaded save, but this is the site the -- original recomputes at, and it also covers a mon handed in by a mod. - Stats.ensure(game.data.pokemon[mon.species], mon) + if isGen2() then require('src.pokemon.Pokemon').refreshStats(game.data,mon) + else Stats.ensure(game.data.pokemon[mon.species],mon)end local self = setmetatable({ game = game, mon = mon, diff --git a/tests/engine/gen2_stat_refresh_data.lua b/tests/engine/gen2_stat_refresh_data.lua new file mode 100644 index 000000000..99fbd1714 --- /dev/null +++ b/tests/engine/gen2_stat_refresh_data.lua @@ -0,0 +1,80 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +T.eq(_G.love,nil,'GSC formula needs no rendering runtime') +for _,name in ipairs{'src.battle.BattleState','src.pokemon.Sprites','src.render.Assets'}do + package.preload[name]=function()error('stat calculation imported presentation: '..name)end +end +local Stats=require('src.pokemon.Stats') +local Mon=require('src.compat.gen2.Mon') +T.check(type(Mon.stats)=='function'and type(Mon.hpDV)=='function','actual source formula entry points exist') +if type(Mon.stats)~='function'or type(Mon.hpDV)~='function' then T.finish('source stat formula APIs are absent')end +-- Independent integer search for the cartridge ceiling-square-root loop. +local function bonus(n) + local root=0 + while root<255 and root*root Date: Thu, 29 Oct 2026 04:49:13 +0100 Subject: [PATCH 183/187] Keep composed GSC source field binding confined to native Gen2 births --- src/pokemon/Pokemon.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 2c5453a74..c88e630ee 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -264,7 +264,7 @@ function Pokemon.new(data, species, level, rng, form, opts) mon.shiny=mon.shiny or Rolls.gen2Shiny(mon,def) mon.gender=Rolls.gender(data,mon,2,level) end - require('src.pokemon.Gen2MonFields').bind(data,mon) + if generation==2 then require('src.pokemon.Gen2MonFields').bind(data,mon) end return mon end From dfe0e44a63e68dba40a5b49d55fef99411fb969d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:50:18 +0100 Subject: [PATCH 184/187] Record independently qualified current-upstream stat and model composition --- docs/native-gen2-stat-refresh.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/native-gen2-stat-refresh.md b/docs/native-gen2-stat-refresh.md index 5bba0e479..53e2f9236 100644 --- a/docs/native-gen2-stat-refresh.md +++ b/docs/native-gen2-stat-refresh.md @@ -2,8 +2,9 @@ Prepared on actual original main `8d5b4387`, composing existing appearance PR373, Unown PR307, battler/model foundation PR396 and field/viewport PR399. -The new combined older host is awaiting its own independent qualification. -No earlier parent receipt qualifies these newly composed bytes. +The combined older host was independently qualified at `26114b40` with all +17 selected native/SDK suites passing. No earlier parent receipt is used to +qualify these newly composed bytes. The adapter adds stats, hpDV, refreshStats(mon,data) and the correctly ordered syncIdentity(mon,data) over real native owners. Its older inherited @@ -22,7 +23,19 @@ Prerequisite conflict resolution keeps current upstream move refusals in the actual BattleCommands owner, both source-composition and output paths, viewport/reset/cancellation services, display/shade palette separation, canonical/numeric registry IDs, field binding and original author credits. -All of that composition still requires actual runtime/contract tests. +The first combined run (`7782ccdc`) caught a real integration regression: +Gen2 field metatables were attached to ten non-GSC editions. The native birth +boundary now applies that binding only to generation 2; the same unchanged +negative generation checks pass on the corrected tree. + +Independent corrected receipt: +`native-selected-suites-1793245754545259000/receipt.json`: 185360 formula, +238 native refresh, 184 data fields, 170 native fields, 577 battler data, +810 native battler construction, 5290 identity, 63 identity transactions, +66245 Unown, 4454 frame queries, 116 chrome, 1031 screen position, +5 composition seam, 1095 status, 617 picture, 1490 vanish and 300 move +presentation checks. These use the actual composed older engine/SDK owners. +This does not claim full-suite, GPU or packaged release qualification. Newer fork source273692e8 passes185360 pure-data and238 actual native checks, plus summary/identity/field/battler/checkpoint regressions; its predecessor From 5c304d6c80a9adfceb963a8b346d92bc8e5b80a9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:10:40 +0100 Subject: [PATCH 185/187] Prepare native GSC PP ownership and source Mon API on current upstream --- docs/native-gen2-mon-pp.md | 29 ++++++++++++++++ src/battle/BattleState.lua | 2 +- src/battle/HeldItems.lua | 2 +- src/battle/WideBattle.lua | 2 +- src/compat/gen2/Mon.lua | 6 ++++ src/core/SaveData.lua | 3 ++ src/inventory/ItemEffects.lua | 14 +++++--- src/link/Protocol.lua | 2 +- src/pokemon/Gen2PP.lua | 65 +++++++++++++++++++++++++++++++++++ src/pokemon/Pokemon.lua | 14 +++++++- src/script/Gen2Commands.lua | 2 +- 11 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 docs/native-gen2-mon-pp.md create mode 100644 src/pokemon/Gen2PP.lua diff --git a/docs/native-gen2-mon-pp.md b/docs/native-gen2-mon-pp.md new file mode 100644 index 000000000..e5513ee34 --- /dev/null +++ b/docs/native-gen2-mon-pp.md @@ -0,0 +1,29 @@ +# Native GSC PP source API and ownership + +Prepared on the independently selected-qualified current-upstream composition +from PR400 (`dfe0e44a`, original main `8d5b4387`). This adds the actual native +Gen2 PP arithmetic prerequisite and the source `ppUpsOf`, `maxPpOf`, +`partyMoves`, `restoreAllPp` and `repairMoves` signatures. Native heal and +real save validation share the same actual move-slot operations. Existing +native item, held-item, battle/wide HUD, script and link calculations use that +maximum too, including the seven-point GSC bonus cap, legacy saved maxima +and SKETCH's PP-Up refusal. Other generations retain their existing formula. + +The arithmetic groundwork is the scoped production portion of existing +commit `03c0534e`. Its separate UI selector and unavailable older menu-result +producers are not copied or presented as implemented by this change. This +host's inherited RNG constructor, the remaining source Mon API, standalone +battle/turn APIs, native selector UI parity and complete visual/release +acceptance remain separate open requirements. + +Qualification of this combined older host is pending. The new source-API +fixture exercises the real older SDK, native healing, actual save validation, +sparse boxes and all breeding slots. A second test exercises actual ETHER, +MYSTERYBERRY, PP UP and link reconstruction. The newer fork's 6642 source-API +checks and six native regression groups do not qualify these older bytes. +No mod, Gen1 reference or player installation is edited. + +Integration/tests by Roxas2712 are offered under +https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md, +including its specific Cedric / UNDERdecoded permission with visible credit. +Existing engine authors' rights, source notices and earlier grants remain. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 795189d91..7e1b77592 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -12823,7 +12823,7 @@ function BattleState:drawTextAreaInner() -- the type record's display name (a mod type shows its name, and -- PSYCHIC_TYPE prints PSYCHIC like the original) Font.draw(def.type and TypeChart.displayName(def.type) or "", 16, 80) - local maxPP = def.pp + (sel.ppUps or 0) * math.floor(def.pp / 5) + local maxPP = require("src.pokemon.Pokemon").maxPP(def, sel) Font.draw(("%2d/%2d"):format(sel.pp, maxPP), 40, 88) end end diff --git a/src/battle/HeldItems.lua b/src/battle/HeldItems.lua index fd0767743..30f5f14bc 100644 --- a/src/battle/HeldItems.lua +++ b/src/battle/HeldItems.lua @@ -388,7 +388,7 @@ local function restorePP(data, holder) local def = data.moves and data.moves[move.id] local base = def and tonumber(def.pp) local maxPP = base - if base then maxPP = base + (tonumber(move.ppUps) or 0) * math.floor(base / 5) end + if base then maxPP = require("src.pokemon.Pokemon").maxPP(def,move,data.constants and data.constants.gen) end local amount = norm(move.id) == "SKETCH" and 1 or 5 move.pp = maxPP and math.min(maxPP, amount) or amount if holder.curMoves and holder.curMoves ~= mon.moves and holder.curMoves[i] then diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 71b4bcafc..3750b5de3 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -326,7 +326,7 @@ local function drawMoveDetails(battle, move) if not move then return end local def = battle.data.moves[move.id] if not def then return end - local maxPP = def.pp + (move.ppUps or 0) * math.floor(def.pp / 5) + local maxPP = require("src.pokemon.Pokemon").maxPP(def, move) love.graphics.setColor(0, 0, 0, 1) Font.draw(("PP %2d/%2d"):format(move.pp or 0, maxPP), 232, 112) Font.draw(fitName(TypeChart.displayName(def.type), 64), 232, 128) diff --git a/src/compat/gen2/Mon.lua b/src/compat/gen2/Mon.lua index 4cd35118d..62b145fa7 100644 --- a/src/compat/gen2/Mon.lua +++ b/src/compat/gen2/Mon.lua @@ -5,6 +5,12 @@ -- source options-record construction remains a separate capability. local Native=require('src.pokemon.Pokemon') local Mon={} +local PP=require('src.pokemon.Gen2PP') +function Mon.ppUpsOf(move,data)return PP.ups(data and data.moves and type(move)=='table' and data.moves[move.id],move)end +function Mon.maxPpOf(move,data)return PP.forSlot(move,data and data.moves)end +function Mon.partyMoves(mon)return PP.partyMoves(mon)end +function Mon.restoreAllPp(mon,data)return PP.restore(mon,data and data.moves)end +function Mon.repairMoves(mon,data)return PP.repair(mon,data and data.moves)end function Mon.hpDV(dvs)return require('src.pokemon.Stats').hpDV(dvs)end function Mon.stats(base,dvs,level,statExp) return require('src.pokemon.Stats').calcGen2(base,dvs,level,statExp) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index a2918783e..1f0f7e796 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -2019,6 +2019,9 @@ local function scrubKnownMon(mon, data) moves[1] = { id = fallback, pp = def.pp } end end + if require('src.core.GameVersion').isGen2() then + require('src.pokemon.Gen2PP').repair(mon,data.moves) + end end -- `slots` is how many places the list HAS, not how many are filled: a party is diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 99f6e829b..9730b5195 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -1124,7 +1124,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local amount = itemId == "MYSTERYBERRY" and 5 or 10 local function restore(mv) local mdef = data.moves[mv.id] - local maxPP = mdef and (mdef.pp + (mv.ppUps or 0) * math.floor(mdef.pp / 5)) + local maxPP = mdef and require("src.pokemon.Pokemon").maxPP(mdef, mv) if maxPP and mv.pp < maxPP then mv.pp = full and maxPP or math.min(maxPP, mv.pp + amount) return true @@ -1323,10 +1323,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if not target then return "failed", { Strings("It won't have\nany effect.") } end local mv = target.moves[moveIndex or 1] local mdef = mv and data.moves[mv.id] - if mdef and (mv.ppUps or 0) < 3 then - mv.ppUps = (mv.ppUps or 0) + 1 - -- each PP UP adds maxPP/5 uses on top of the base maximum - mv.pp = mv.pp + math.floor(mdef.pp / 5) + local gen2 = require("src.core.GameVersion").isGen2() + local ups = gen2 and require("src.pokemon.Gen2PP").ups(mdef, mv) or mv and (mv.ppUps or 0) or 0 + if mdef and ups < 3 and not (gen2 and mv.id == "SKETCH") then + mv.ppUps = ups + 1 + local bonus = math.floor(mdef.pp / 5) + if gen2 then bonus = require("src.pokemon.Gen2PP").bonus(mdef.pp) end + mv.pp = mv.pp + bonus + if gen2 then mv.maxPp = require("src.pokemon.Pokemon").maxPP(mdef, mv, 2) end return "consumed", { Strings("%s's PP\nincreased!", mdef.name) } end return "failed", { Strings("It won't have\nany effect.") } diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index 787c37899..0e8398c8c 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -114,7 +114,7 @@ function Protocol.unpackMon(data, packed, opts) local mdef = data.moves[mv.id] if mdef and #moves < 4 then local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0))) - local maxPP = mdef.pp + ppUps * math.floor(mdef.pp / 5) + local maxPP = require("src.pokemon.Pokemon").maxPP(mdef, { ppUps=ppUps }, data.constants and data.constants.gen) local entry = { id = mv.id, pp = math.max(0, math.min(maxPP, math.floor(mv.pp or 0))) } if mv.ppUps ~= nil then entry.ppUps = ppUps end diff --git a/src/pokemon/Gen2PP.lua b/src/pokemon/Gen2PP.lua new file mode 100644 index 000000000..90275504f --- /dev/null +++ b/src/pokemon/Gen2PP.lua @@ -0,0 +1,65 @@ +-- Native Gen2 PP arithmetic from Gen1Recomp 535a5ec0 Mon.lua. +-- Existing project rights/licences remain applicable. Contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +local P = {} +function P.bonus(base) return math.min(math.floor(base / 5), 7) end +function P.ups(def, slot) + if type(slot) ~= 'table' then return 0 end + local ups = tonumber(slot.ppUps) + if ups == nil then + local base, stored = def and tonumber(def.pp), tonumber(slot.maxPp) + ups = 0 + if base and stored and stored > base then + local bonus = P.bonus(base) + if bonus > 0 and (stored - base) % bonus == 0 then + ups = (stored - base) / bonus + end + end + end + return math.max(0, math.min(3, math.floor(ups))) +end +function P.maximum(def, slot) + local base = def and tonumber(def.pp) + if not base then + return type(slot) == 'table' and (tonumber(slot.maxPp) or tonumber(slot.pp)) or 0 + end + return base + P.ups(def, slot) * P.bonus(base) +end + +-- Source consumers identify the saved slot first, then supply the live move +-- registry. Keep these operations on those same native tables. +local function definition(slot, moves) + return type(slot)=='table' and moves and moves[slot.id] or nil +end +function P.forSlot(slot, moves) + if type(slot)~='table' then return 0 end + return P.maximum(definition(slot,moves),slot) +end +function P.partyMoves(mon) + if type(mon)~='table' then return {} end + local before=type(mon.volatile)=='table' and mon.volatile.preTransform + return (before and before.moves) or mon.moves or {} +end +function P.restore(mon, moves) + for _,slot in ipairs(type(mon)=='table' and mon.moves or {}) do + if type(slot)=='table' and slot.id then + local def=definition(slot,moves) + local maximum=P.maximum(def,slot) + if def and tonumber(def.pp) then slot.maxPp=maximum end + slot.pp=maximum + end + end +end +function P.repair(mon, moves) + for _,slot in ipairs(type(mon)=='table' and mon.moves or {}) do + local def=definition(slot,moves) + if def and tonumber(def.pp) then + local ups=P.ups(def,slot) + if ups>0 or slot.ppUps~=nil then slot.ppUps=ups end + local maximum=P.maximum(def,slot) + slot.maxPp=maximum + slot.pp=math.max(0,math.min(maximum,math.floor(tonumber(slot.pp) or maximum))) + end + end +end +return P diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index c88e630ee..558912c37 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -2,6 +2,7 @@ local Growth = require("src.pokemon.Growth") local Stats = require("src.pokemon.Stats") +local Gen2PP = require("src.pokemon.Gen2PP") local Pokemon = {} @@ -537,7 +538,15 @@ end -- Pokémon Center / blackout heal (engine/events/heal_party.asm -- HealParty): full HP, status cleared, and every move's PP restored to -- its base plus the PP-Up bonus (RestoreBonusPP adds maxPP/5 per PP UP). -function Pokemon.maxPP(def, slot) +function Pokemon.ppUps(def, slot, generation) + generation = tonumber(generation) or require('src.core.GameVersion').generation() + if generation == 2 then return Gen2PP.ups(def, slot) end + return type(slot)=='table' and math.min(3,math.max(0,math.floor(tonumber(slot.ppUps) or 0))) or 0 +end + +function Pokemon.maxPP(def, slot, generation) + generation = tonumber(generation) or require('src.core.GameVersion').generation() + if generation == 2 then return Gen2PP.maximum(def, slot) end local base = math.max(0,tonumber(def and def.pp) or 0) local ups = type(slot)=='table' and math.min(3,math.max(0,math.floor(tonumber(slot.ppUps) or 0))) or 0 return base + ups * math.floor(base / 5) @@ -545,6 +554,9 @@ end function Pokemon.restorePP(mon, moves) moves = moves or require("src.core.Data").moves + if require('src.core.GameVersion').isGen2() then + return Gen2PP.restore(mon,moves) + end if moves then for _, mv in ipairs(mon.moves or {}) do local mdef = moves[mv.id] diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index 80a044850..2b86cfabb 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -4363,7 +4363,7 @@ function Gen2Commands.dayCareWithdraw(ctx, which) mon.status = nil for _, move in ipairs(mon.moves or {}) do local moveDef = game.data.moves and game.data.moves[move.id] - if moveDef then move.pp = moveDef.pp + (move.ppUps or 0) * math.floor(moveDef.pp / 5) end + if moveDef then move.pp = Pokemon.maxPP(moveDef, move, 2) end end end table.insert(save.party, mon) From 94f819f1e6bba53c56eba1d4b9cb247c9412ab5a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:10:52 +0100 Subject: [PATCH 186/187] Include explicit older-host source PP and native boundary regressions --- tests/engine/gen2_mon_pp.lua | 115 +++++++++++++++++++++++++++++ tests/engine/gen2_pp_ownership.lua | 47 ++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/engine/gen2_mon_pp.lua create mode 100644 tests/engine/gen2_pp_ownership.lua diff --git a/tests/engine/gen2_mon_pp.lua b/tests/engine/gen2_mon_pp.lua new file mode 100644 index 000000000..e0a128d25 --- /dev/null +++ b/tests/engine/gen2_mon_pp.lua @@ -0,0 +1,115 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Pokemon=require('src.pokemon.Pokemon') +local PP=require('src.pokemon.Gen2PP') +local Save=require('src.core.SaveData') +local oldVersion=Version.get() +local function clone(t) + if type(t)~='table' then return t end + local out={};for k,v in pairs(t)do out[k]=clone(v)end;return out +end +for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + local loaded=T.sdk.loadMods({'mods/pp_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/pp_probe/manifest.json']='{"id":"pp_probe","name":"PP probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/pp_probe/main.lua']=[[ + local mod=... + local Mon=require('src.battle.gen2.Mon') + mod.exports.api=Mon + ]], + })}) + T.eq(#loaded.errors,0,'actual SDK loads unchanged source module name') + local Mon=loaded.loader.exports.pp_probe.api + local present=true + for _,name in ipairs({'ppUpsOf','maxPpOf','partyMoves','restoreAllPp','repairMoves'})do + T.eq(type(Mon[name]),'function',edition..' source PP method '..name) + present=present and type(Mon[name])=='function' + end + if present then + data.moves.FIX_TACKLE.pp=40 + for base=1,64 do + data.moves.FIX_TACKLE.pp=base + for ups=0,3 do + local bonus=math.min(math.floor(base/5),7) + local expected=base+ups*bonus + local slot={id='FIX_TACKLE',pp=0,ppUps=ups} + T.eq(Mon.maxPpOf(slot,data),expected,'source maximum uses capped native Gen2 arithmetic') + T.eq(Mon.ppUpsOf(slot,data),ups,'source reads actual PP-Up counter') + T.eq(slot.pp,0,'reading maximum cannot restore PP') + local legacy={id='FIX_TACKLE',pp=0,maxPp=expected} + T.eq(Mon.maxPpOf(legacy,data),expected,'legacy saved maximum follows actual native bonus') + T.eq(legacy.ppUps,nil,'reading legacy bonus does not stamp a field') + end + end + data.moves.FIX_TACKLE.pp=40 + T.eq(Mon.maxPpOf(nil,data),0,'absent source move has zero maximum') + T.eq(Mon.ppUpsOf(nil,data),0,'absent source move has zero upgrades') + T.eq(Mon.maxPpOf({id='UNKNOWN',maxPp='23',pp=9},data),23,'unknown source move retains authored maximum') + T.eq(Mon.maxPpOf({id='UNKNOWN',pp='9'},data),9,'unknown source move retains authored PP fallback') + T.eq(Mon.maxPpOf({id='FIX_TACKLE',ppUps=9},data),61,'excess PP Ups clamp to three') + T.eq(Mon.maxPpOf({id='FIX_TACKLE',ppUps=-1},data),40,'negative PP Ups clamp to zero') + T.eq(Mon.maxPpOf({id='FIX_TACKLE',maxPp=53},data),40,'malformed legacy maximum cannot infer a fractional bonus') + local mon=Pokemon.new(data,'FIXMON_A',20) + mon.moves={{id='FIX_TACKLE',pp=99,maxPp=54},{id='FIX_SCRATCH',pp=-4,ppUps=0}, + {id='UNKNOWN',pp=2,maxPp=8}} + local moves,first=mon.moves,mon.moves[1] + Mon.repairMoves(mon,data) + T.eq(mon.moves,moves,'repair retains actual party list identity') + T.eq(mon.moves[1],first,'repair retains actual slot identity') + T.eq(first.ppUps,2,'repair migrates valid legacy bonus') + T.eq(first.maxPp,54,'repair retains inferred maximum') + T.eq(first.pp,54,'repair clamps overfull actual PP') + T.eq(mon.moves[2].pp,0,'repair clamps negative PP') + T.eq(mon.moves[3].pp,2,'repair leaves unknown definition untouched') + T.eq(Mon.partyMoves(mon),moves,'source reads actual original party moves') + local pre={moves={{id='FIX_SCRATCH',pp=3}}} + mon.volatile={preTransform=pre} + T.eq(Mon.partyMoves(mon),pre.moves,'source Transform snapshot reads original saved moves') + mon.volatile=nil + first.pp=0 + Mon.restoreAllPp(mon,data) + T.eq(first.pp,54,'source restore heals actual slot') + T.eq(mon.moves[3].pp,8,'source restore uses unknown authored fallback') + first.pp=0;first.maxPp=999 + Pokemon.restorePP(mon,data.moves) + T.eq(first.pp,54,'native healing shares source PP owner') + T.eq(first.maxPp,54,'native healing publishes actual current maximum') + local save=Save.newGame() + local Boxes=require('src.pokemon.Boxes') + Boxes.load(data);Boxes.ensure(save) + local function wounded() + local m=Pokemon.new(data,'FIXMON_A',20) + m.moves={{id='FIX_TACKLE',pp=99,maxPp=61}} + return m + end + save.party={wounded()};save.boxes[1][7]=wounded() + save.daycare={mon=wounded(),breed={{mon=wounded()},{mon=wounded()},egg=wounded()}} + local decoded=assert(Save.decode(Save.encode(save))) + local party=decoded.party[1] + Save.validate(decoded,data) + T.eq(decoded.party[1],party,'actual save validation retains original native Pokemon') + for _,m in ipairs({decoded.party[1],decoded.boxes[1][7],decoded.daycare.mon, + decoded.daycare.breed[1].mon,decoded.daycare.breed[2].mon,decoded.daycare.breed.egg})do + T.eq(m.moves[1].pp,61,'actual restore boundary repairs overfull party/box/daycare PP') + T.eq(m.moves[1].ppUps,3,'actual restore boundary infers saved native bonus') + T.eq(m.moves[1].maxPp,61,'actual restore boundary publishes native maximum') + end + local encoded=Save.encode(decoded) + Save.validate(decoded,data) + T.eq(Save.encode(decoded),encoded,'repeated real save validation is stable') + end + loaded.release() +end +for _,edition in ipairs({'red','firered','emerald','platinum','heartgold','black'})do + Version.set(edition) + local data=T.fixtures.fresh();data.moves.FIX_TACKLE.pp=40 + local mon={moves={{id='FIX_TACKLE',pp=0,ppUps=3}}} + Pokemon.restorePP(mon,data.moves) + T.eq(mon.moves[1].pp,64,'other native generations retain their PP rule') + T.eq(mon.moves[1].maxPp,nil,'other native healing does not stamp GSC maximum') +end +Version.set(oldVersion) +T.finish('source GSC PP methods, actual SDK slots, native healing and restored saves') diff --git a/tests/engine/gen2_pp_ownership.lua b/tests/engine/gen2_pp_ownership.lua new file mode 100644 index 000000000..160bbaa14 --- /dev/null +++ b/tests/engine/gen2_pp_ownership.lua @@ -0,0 +1,47 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Pokemon=require('src.pokemon.Pokemon') +local Effects=require('src.inventory.ItemEffects') +local Protocol=require('src.link.Protocol') +local previous=V.get() +for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.FIX_TACKLE.pp=40 + local mon=Pokemon.new(data,'FIXMON_A',30) + local save={party={mon}} + mon.moves={{id='FIX_TACKLE',pp=59,ppUps=3},{id='FIX_SCRATCH',pp=0}} + T.eq(Effects.use(data,save,'ETHER',mon,nil,1),'consumed','actual ETHER restores selected GSC slot') + T.eq(mon.moves[1].pp,61,'actual ETHER uses capped GSC maximum') + T.eq(Effects.use(data,save,'ETHER',mon,nil,1),'failed','full actual native slot refuses further PP restoration') + mon.moves[1].pp=58 + T.eq(Effects.use(data,save,'MYSTERYBERRY',mon,nil,1),'consumed','actual berry restores selected native slot') + T.eq(mon.moves[1].pp,61,'actual berry shares native GSC maximum') + T.eq(mon.moves[2].pp,0,'single-slot restoration preserves another depleted move') + mon.moves[1]={id='FIX_TACKLE',pp=52,maxPp=54} + T.eq(Effects.use(data,save,'PP_UP',mon,nil,1),'consumed','actual PP UP recognizes legacy native bonus') + T.eq(mon.moves[1].ppUps,3,'actual PP UP updates real inferred counter') + T.eq(mon.moves[1].maxPp,61,'actual PP UP publishes real capped maximum') + T.eq(mon.moves[1].pp,59,'actual PP UP adds capped seven-point bonus') + T.eq(Effects.use(data,save,'PP_UP',mon,nil,1),'failed','actual native counter refuses a fourth bonus') + data.moves.SKETCH={id='SKETCH',name='SKETCH',pp=1} + mon.moves[1]={id='SKETCH',pp=1} + T.eq(Effects.use(data,save,'PP_UP',mon,nil,1),'failed','actual GSC SKETCH refuses PP UP') + T.eq(mon.moves[1].ppUps,nil,'refused item preserves real saved counter') + mon.moves[1]={id='FIX_TACKLE',pp=61,ppUps=3,maxPp=61} + local packet=Protocol.packMon(mon) + packet.moves[1].pp=64 + local other=Protocol.unpackMon(data,packet) + T.check(other~=nil,'actual native link reconstruction returns Pokemon') + T.eq(other and other.moves[1].pp,61,'actual native link clamps to destination GSC maximum') + T.eq(mon.moves[1].pp,61,'detached link input cannot mutate original party member') +end +for _,edition in ipairs({'red','firered','emerald','platinum','heartgold','black'})do + V.set(edition) + T.eq(Pokemon.maxPP({pp=40},{ppUps=3}),64,'other generations retain existing PP formula') + T.eq(Pokemon.maxPP({pp=40},{ppUps=3},2),61,'explicit destination generation overrides current preview') +end +V.set(previous) +T.finish('actual older native GSC item, legacy PP-Up, berry and link ownership') From 7c218f3a4abfda41a786b809c4eb07b3b5fa7e54 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:26:30 +0100 Subject: [PATCH 187/187] Record independent older-host PP and native ownership qualification --- docs/native-gen2-mon-pp.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/native-gen2-mon-pp.md b/docs/native-gen2-mon-pp.md index e5513ee34..79574c0e9 100644 --- a/docs/native-gen2-mon-pp.md +++ b/docs/native-gen2-mon-pp.md @@ -16,11 +16,15 @@ host's inherited RNG constructor, the remaining source Mon API, standalone battle/turn APIs, native selector UI parity and complete visual/release acceptance remain separate open requirements. -Qualification of this combined older host is pending. The new source-API +The actual combined older host `94f819f1` independently passes all seven +selected suites:6642 source PP checks,92 actual item/link ownership checks, +170 native fields,238 stat refresh,810 native battler construction,5290 +appearance identity and4454 compositor/frame-query checks. Receipt: +`native-selected-suites-1793247889378083000/receipt.json`. The new source-API fixture exercises the real older SDK, native healing, actual save validation, -sparse boxes and all breeding slots. A second test exercises actual ETHER, -MYSTERYBERRY, PP UP and link reconstruction. The newer fork's 6642 source-API -checks and six native regression groups do not qualify these older bytes. +sparse boxes and all breeding slots. The second test exercises actual ETHER, +MYSTERYBERRY, PP UP and link reconstruction. Newer-fork receipts are not used +to qualify these older bytes. No full-suite/GPU/release acceptance is claimed. No mod, Gen1 reference or player installation is edited. Integration/tests by Roxas2712 are offered under