From 644aa8364b83438edc98e652cb1e5cf04e1bf1ff Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:07:49 +0200 Subject: [PATCH 01/71] Add version-aware capability queries to the mod API --- docs/modding.md | 50 +++++++ src/mods/Capabilities.lua | 108 +++++++++++++++ src/mods/Loader.lua | 6 + tests/engine/mod_supports.lua | 248 ++++++++++++++++++++++++++++++++++ 4 files changed, 412 insertions(+) create mode 100644 src/mods/Capabilities.lua create mode 100644 tests/engine/mod_supports.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..39892ef6a 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -18,6 +18,56 @@ Regenerate the reference straight into a wiki checkout: luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +## Feature queries (`mod:supports`) + +`mod:supports(surface, name)` returns `true` when the selected host exposes an +existing API-2 capability, or `false, reason` when it does not. Unknown names, +unknown surfaces and invalid names return `false` with an explanation. Check +for the method itself when supporting older engine builds: + +```lua +if mod.supports and mod:supports("hooks", "battle.damage") then + mod.hooks:wrap("battle.damage", function(next, ctx) + return next(ctx) + end) +end + +local canFly = mod.supports and mod:supports("world", "flyTo") -- false here +``` + +The four surfaces are: + +| Surface | Meaning of `true` | +| --- | --- | +| `content` | An API-2 registry exists, e.g. `pokemon` or `render_pipelines`. | +| `world` | A public `mod.world` method exists, e.g. `warpTo` or `terrainAt`. | +| `hooks` | The engine has a native hook call site for this version. | +| `events` | The engine has a native event call site for this version, or the name is a non-empty `mod..` namespace. | + +Queries work in the entry chunk without an active overworld, save, graphics +context or imported ROM. They do not subscribe, wrap hooks, load the Game or +materialize `mod.world`. Answers are bound to the host selected when that mod +API was built; changing the mod's copy of `gameVersion` or `host` cannot change +them. Availability does not imply the current game state permits the action: +`warpTo` can still return `nil, "no overworld"`, and elevation readers can +return `nil, "map carries no elevation"`. + +Callback availability follows Gen2Recomped's actual implementation, rather +than Gen1Recomp's newer catalog. For example, `core.logic_speed`, `item.use` +and `egg.hatched` have no native call sites here; the hatch event here is +`pokemon.egg_hatched` (Gen 2–5). `battle.status_hud_visible` is available on +Gen 4–5, `battle.overlay` on Gen 1–3, and the normal start-menu, party-submenu +and naming-grid hooks on Gen 1–3. `ui.pc.items` is available on Gen 1–2. +Oak-speech callbacks apply to Gen 1–2 except Prism; Birch-speech events apply +to Emerald. Other versions use their own intros. An event still needs its +trigger to occur; custom events also need another mod to emit them. + +This is additive to Mod API 2: it neither normalizes content records, IDs or +callback payloads across generations nor provides Gen1Recomp's unified API 3. +Existing registration/subscription behavior remains unchanged. When adding a +native callback or changing screen routing, update `src/mods/Capabilities.lua` +and the capability tests in the same change. + ## Bulk storage (`mod.storage`) `mod.save` is the right home for a handful of per-playthrough values: it lives diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua new file mode 100644 index 000000000..32da142d7 --- /dev/null +++ b/src/mods/Capabilities.lua @@ -0,0 +1,108 @@ +-- Availability of the existing API-2 surfaces, before a Game/overworld exists. +-- Keep callback names tied to native call sites, not to the permissive buses: +-- successfully subscribing to a name does not mean the engine ever fires it. +local GameVersion = require("src.core.GameVersion") +local Schemas = require("src.mods.Schemas") + +local Capabilities = {} +local callbacks = { hooks = {}, events = {} } +local function add(surface, generations, names) + for name in names:gmatch("%S+") do + callbacks[surface][name] = generations + end +end + +-- Shared simulation, world, input, rendering and lifecycle code. A callback +-- need not fire on every action (e.g. an always-hit move skips accuracy). +add("hooks", "12345", [[ + battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action + battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate + encounter.fishing encounter.roll encounter.species evolution.check exp.gain + fieldmove.eligibility input.step link.fingerprint map.palette movement.collision + movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite + render.compose render.hud render.letterbox render.zones save.new_game save.write + script.command trainer.party transition.style ui.list_menu ui.options.rows + ui.title_menu.items warp.destination world.tod zoom.range +]]) +-- Screens have distinct implementations: Screens.lua aliases the DS party, +-- naming and start menus; those implementations do not call the GB/GBA hooks. +-- OverworldController:openPC bypasses ui.pc.items on Gen 3/4 (and Gen 5 uses +-- its own script PC). Gen4Battle paints its HUD through a separate seam and +-- does not dispatch battle.overlay, unlike BattleState/Gen3Battle/WideBattle. +add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_menu.items") +add("hooks", "12", "ui.pc.items") +add("hooks", "45", "battle.status_hud_visible") +add("hooks", "12", "intro.oak_speech.build") + +add("events", "12345", [[ + assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt + battle.ended battle.exp_gained battle.fainted battle.move_used battle.started + battle.status_inflicted battle.turn_ended battle.turn_started discord.join_requested + flag.changed game.ready link.connected link.desync link.ended map.entered map.exited + map.reloaded mod.option_action mod.options_changed mods.loaded music.started + music.stopped player.warped pokemon.before_give pokemon.caught pokemon.evolved + pokemon.level_up pokemon.move_learned pokemon.received save.created save.loaded + save.loading save.writing screen.popped screen.pushed script.ended script.started + sound.played trade.completed world.blacked_out world.block_replaced + world.blocks_replaced world.boulder_moved world.interacted world.npc_spawned + world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged +]]) +add("events", "2345", "pokemon.egg_hatched") +add("events", "12", [[ + intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started + intro.oak_speech.step +]]) +add("events", "3", "intro.birch_speech.started intro.birch_speech.finished") + +-- Data:applyDefaults selects BirchSpeech on Emerald, Gen3OakSpeechFRLG on +-- FireRed, and no OakSpeech on Prism. DS imports select their own intros. +local function nativeVersion(name, version) + if name:match("^intro%.birch_speech%.") then return version == "emerald" end + if name:match("^intro%.oak_speech%.") then return version ~= "prism" end + return true +end + +-- Public WorldAPI methods only; constructors, internal helpers and methods +-- on returned NPC handles are not mod.world methods. These remain callable +-- at the title screen, returning their normal "no overworld"/"no save" error. +-- Elevation readers can return "map carries no elevation" on any dataset. +local world = {} +for name in ([[ + overworld current warpTo toggleObject setFlag getFlag replaceBlock spawnNpc + removeNpc loadedMaps elevationInfo elevationAt terrainAt elevationRanks heightAt + elevationGrid npc queueScript invalidateMap +]]):gmatch("%S+") do world[name] = true end + +function Capabilities.supports(surface, name, version) + if surface ~= "content" and surface ~= "world" + and surface ~= "hooks" and surface ~= "events" then + return false, "unsupported capability surface: " .. tostring(surface) + end + if type(name) ~= "string" or name == "" then + return false, "capability name must be a non-empty string" + end + local info = GameVersion.VERSIONS[version] + if not info then return false, "unknown game version: " .. tostring(version) end + if surface == "content" then + -- Every loader builds the same API-2 registries from Schemas. This says + -- the registration API exists, not that record shapes/IDs are portable. + if Schemas.REGISTRIES[name] then return true end + elseif surface == "world" then + if world[name] then return true end + else + -- A mod may listen to another mod's namespace without that mod installed; + -- this only promises the bus supports it, never that a producer is present. + if surface == "events" and name:match("^mod%.[%w_%-]+%..+") then return true end + local gens = callbacks[surface][name] + if gens then + if gens:find(tostring(info.generation), 1, true) and nativeVersion(name, version) then + return true + end + return false, ("%s capability %s has no native call site in %s") + :format(surface, name, version) + end + end + return false, ("unknown %s capability: %s"):format(surface, name) +end + +return Capabilities diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..536e2de97 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -16,6 +16,7 @@ local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") local Registry = require("src.mods.Registry") local Schemas = require("src.mods.Schemas") +local Capabilities = require("src.mods.Capabilities") local Semver = require("src.mods.Semver") local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") @@ -1119,6 +1120,11 @@ function Loader:_api(mod) end return data end) + -- Capture the host selected for this API, as with generation/gameVersion. + -- Probing must not instantiate mod.world/mod.game or register callbacks. + function api:supports(surface, name) + return Capabilities.supports(surface, name, hostId) + end -- mod.world / mod.game / mod.storage all materialize on first touch, for -- the same reason: a headless load must not drag the world stack in, and -- the Game the facade acts on is still being wired when the entry chunk diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua new file mode 100644 index 000000000..7e3bf96f5 --- /dev/null +++ b/tests/engine/mod_supports.lua @@ -0,0 +1,248 @@ +-- API availability is observable through the real loader, before Game/world +-- construction. No imported content, graphics, or network is required. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.harness') +local GameVersion = require('src.core.GameVersion') +local Loader = require('src.mods.Loader') +local Runtime = require('src.mods.Runtime') +local Schemas = require('src.mods.Schemas') +local Catalog = require('tests.modkit.catalog') +local originalEvents, originalHooks, originalErrors = Runtime.events, Runtime.hooks, Runtime.errors +local originalVersion = GameVersion.get() +local savedLove = love +love = nil + +local files = { + ['mods/probe/manifest.json'] = [[{"id":"probe","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua"}]], + ['mods/probe/main.lua'] = [[ +return function(mod) + -- Runs inside the entry window, not after the loader has finished. + mod.exports.early = mod:supports('world', 'warpTo') + mod.exports.api = mod +end +]], +} +local fs = { + read = function(path) return files[path] end, + getDirectoryItems = function(path) + if path == 'mods' then return { 'probe' } end + return {} + end, + getInfo = function(path) + if path == 'mods' or path == 'mods/probe' then return { type = 'directory' } end + if files[path] then return { type = 'file' } end + end, + load = function(path) return loadstring(files[path], path) end, +} +local function probe(version) + GameVersion.set(version) + local data = { pokemon = {} } + local loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() error('supports must not construct Game') end + assert(loader:load(data)) + assert(loader.apis.probe, tostring(loader.errors.probe)) + return loader.apis.probe, loader, data +end +local function yes(api, surface, name) + local ok, reason = api:supports(surface, name) + return ok == true and reason == nil +end +local function no(api, surface, name) + local ok, reason = api:supports(surface, name) + return ok == false and type(reason) == 'string' and #reason > 0 +end + +-- Builtin registrants can allocate fresh closures on each load. Compare +-- record structure and values while treating function identity as opaque, +-- as the registry parity gate does. +local function record(value) + if type(value) == 'function' then return '' end + if type(value) ~= 'table' then return value end + local out = {}; for k, v in pairs(value) do out[k] = record(v) end; return out +end + +local allHooks, allEvents = {}, {} +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, data = probe(version) + T.check(api.exports.early, version .. ': query succeeds inside entry chunk') + T.check(yes(api, 'hooks', 'battle.damage') and yes(api, 'events', 'world.stepped'), + version .. ': shared simulation/world call sites available') + local registries = true + for name in pairs(Schemas.REGISTRIES) do + registries = registries and yes(api, 'content', name) + end + T.check(registries, version .. ': all API-2 registration surfaces available') + T.check(no(api, 'world', 'flyTo') and no(api, 'hooks', 'core.logic_speed') + and no(api, 'hooks', 'item.use') and no(api, 'events', 'egg.hatched'), + version .. ': absent Gen1Recomp API-3 features are not advertised') + T.check(yes(api, 'events', 'mod.probe.ready') and yes(api, 'events', 'mod.other.ready'), + version .. ': namespaced user events do not need a producer installed') + for _, name in ipairs(Catalog.hooks()) do + if yes(api, 'hooks', name) then allHooks[name] = true end + end + for _, name in ipairs(Catalog.events()) do + if yes(api, 'events', name) then allEvents[name] = true end + end + -- Mutation of the handed-out metadata cannot change a capability answer. + local statusHud = api:supports('hooks', 'battle.status_hud_visible') + api.gameVersion, api.generation, api.host.version = 'red', 1, 'red' + GameVersion.set('red') + T.eq(api:supports('hooks', 'battle.status_hud_visible'), statusHud, + version .. ': query retains captured host even after metadata/global changes') + T.check(next(loader.events.listeners) == nil and next(loader.hooks.chains) == nil, + version .. ': probing installs no listeners or wrappers') + local pristine = { pokemon = {} } + GameVersion.set(version) + local vanilla = Loader.new({ fs = fs, noMods = true, dev = false }) + assert(vanilla:load(pristine)) + T.same(record(data), record(pristine), version .. ': querying mod leaves merged data equal to no-mod boot') +end + +-- The union covers every existing native callback; stale catalog entries +-- also fail. This gate reads source rather than a second hand-kept name list. +T.same(allHooks, (function() + local out = {}; for _, name in ipairs(Catalog.hooks()) do out[name] = true end; return out +end)(), 'all native hook names have a queryable implementation') +T.same(allEvents, (function() + local out = {}; for _, name in ipairs(Catalog.events()) do out[name] = true end; return out +end)(), 'all native event names have a queryable implementation') + +-- Also reject advertised names whose native call site was removed. Read +-- the simple declaration lists without exporting mutable production state. +local h = assert(io.open('src/mods/Capabilities.lua', 'r')) +local body = h:read('*a'); h:close() +local declared = { hooks = {}, events = {} } +for surface, list in body:gmatch('add%("(%w+)", "%d+", ([^%)]+)%)') do + local names = list:match('^%[%[(.-)%]%]$') or list:match('^"(.-)"$') + assert(names, 'unrecognized capability declaration') + for name in names:gmatch('%S+') do declared[surface][name] = true end +end +T.same(declared.hooks, allHooks, 'advertised hook catalog has no stale call sites') +T.same(declared.events, allEvents, 'advertised event catalog has no stale call sites') + +local api, loader = probe('crystal') +local noWorldBefore = package.loaded['src.world.WorldAPI'] +T.check(yes(api, 'world', 'warpTo') and yes(api, 'world', 'heightAt'), + 'world methods available independently of save/map state') +T.eq(package.loaded['src.world.WorldAPI'], noWorldBefore, 'queries never require WorldAPI') +for _, surface in ipairs({ 'content', 'world', 'hooks', 'events' }) do + for _, invalid in ipairs({ '', 1, false, {}, function() end }) do + T.check(no(api, surface, invalid), surface .. ': malformed name declines with reason') + end + T.check(no(api, surface, nil), surface .. ': missing name declines with reason') + T.check(no(api, surface, 'does.not.exist'), surface .. ': unknown name declines with reason') +end +for _, surface in ipairs({ '', 'event', 'hook', 'storage', 1, false, {} }) do + T.check(no(api, surface, 'anything'), 'unknown/malformed surface declines with reason') +end +T.check(no(api, nil, 'anything'), 'missing surface declines with reason') +T.check(no(api, 'events', 'mod.probe.') and no(api, 'events', 'mod..ready'), + 'empty user-event namespace/name is unavailable') +T.check(no(api, 'world', 'new') and no(api, 'world', '_mapNamed') + and no(api, 'world', 'face') and no(api, 'world', '__index'), + 'constructors, internals and NPC-handle methods are not world capabilities') +T.check(no(api, 'content', 'Pokemon') and no(api, 'hooks', 'BATTLE.DAMAGE'), + 'names are case-sensitive') + +-- Verify world catalog against the real public method table, not just names +-- duplicated in a test. A supported method may still decline without a map. +love = savedLove or require('tests.love_stub') +local WorldAPI = require('src.world.WorldAPI') +for name, method in pairs(WorldAPI) do + if type(method) == 'function' and name ~= 'new' and name:sub(1, 1) ~= '_' then + T.check(yes(api, 'world', name), 'public WorldAPI method query: ' .. name) + end +end +local worldNames = assert(body:match('for name in %(%[%[(.-)%]%]%):gmatch')) +for name in worldNames:gmatch('%S+') do + T.eq(type(WorldAPI[name]), 'function', 'advertised world method exists: ' .. name) +end +local world = WorldAPI.new({}, 'probe') +local result, reason = world:warpTo('missing', 0, 0) +T.check(result == nil and reason == 'no overworld' and yes(api, 'world', 'warpTo'), + 'availability is distinct from current-state eligibility') + +-- Version-specific routing is deliberately different from Gen1Recomp. +for _, version in ipairs(GameVersion.ORDER) do + local mod = probe(version) + local gen = GameVersion.generation(version) + T.eq(mod:supports('hooks', 'battle.status_hud_visible'), gen >= 4, + version .. ': DS-only HUD seam') + T.eq(mod:supports('hooks', 'battle.overlay'), gen <= 3, + version .. ': GB/GBA-only overlay seam') + T.eq(mod:supports('hooks', 'ui.party.submenu'), gen <= 3, + version .. ': native party-menu seam') + T.eq(mod:supports('hooks', 'ui.pc.items'), gen <= 2, + version .. ': native PC seam') + T.eq(mod:supports('events', 'intro.birch_speech.started'), version == 'emerald', + version .. ': Emerald-only Birch intro') + T.eq(mod:supports('hooks', 'intro.oak_speech.build'), gen <= 2 and version ~= 'prism', + version .. ': Oak route excludes Prism and newer intros') +end + +-- A content query gates an actual registration through the entry API. +local entry = files['mods/probe/main.lua'] +files['mods/probe/main.lua'] = [[ +return function(mod) + if mod:supports('content', 'strings') then + mod.content.strings:register('QUERY_TEXT', 'Query succeeded') + end + if mod:supports('content', 'not_a_registry') then + error('unavailable registration must be skipped') + end +end +]] +local _, contentLoader, contentData = probe('crystal') +T.eq(contentData.strings.QUERY_TEXT, 'Query succeeded', 'supported registry accepts real registration') +T.eq(#contentLoader.errors, 0, 'unavailable content fallback loads without errors') +files['mods/probe/main.lua'] = entry + +-- A generation-specific query gates the real DS HUD call site. Returning +-- false avoids drawing and lets this run without a graphics context. +local Gen4Battle = require('src.battle.Gen4Battle') +for _, version in ipairs({ 'platinum', 'black' }) do + local mod = probe(version) + local calls = 0 + if mod:supports('hooks', 'battle.status_hud_visible') then + mod.hooks:wrap('battle.status_hud_visible', function() + calls = calls + 1 + return false + end) + end + T.eq(Gen4Battle.drawHealthboxes({}), true, version .. ': supported HUD hook suppresses native boxes') + T.eq(calls, 1, version .. ': native HUD call reaches mod once') +end + +-- A successful probe gates an actual native call: world.timeOfDay dispatches +-- the hook and resulting event. Queries themselves have no bus side effects. +api, loader = probe('crystal') +local calls, changes = 0, 0 +if api:supports('hooks', 'world.tod') then + api.hooks:wrap('world.tod', function(next, tod, ctx) + calls = calls + 1 + return 'TEST_NIGHT' + end) +end +if api:supports('events', 'world.tod_changed') then + api.events:on('world.tod_changed', function(payload) + changes = changes + 1 + T.eq(payload.tod, 'TEST_NIGHT', 'native event carries hook result') + end) +end +local Overworld = require('src.world.OverworldController') +local ow = setmetatable({ tod = 'DAY', map = { id = 'PROBE' }, player = {} }, { __index = Overworld }) +ow.syncGen2Tod = function() end +T.eq(ow:timeOfDay(), 'TEST_NIGHT', 'queried native hook executes through actual world method') +T.check(calls == 1 and changes == 1, 'native hook/event each dispatched once') +-- Unsubscribe and use the same native path: original time-of-day behavior. +loader.events:removeOwner('probe') +loader.hooks:removeOwner('probe') +GameVersion.set('red') +ow.tod = 'DAY' +T.eq(ow:timeOfDay(), 'DAY', 'unmodified native path retains vanilla result') +T.check(calls == 1 and changes == 1, 'no-mod path invokes no mod callbacks') + +GameVersion.set(originalVersion) +love = savedLove +Runtime.install(originalEvents, originalHooks, originalErrors) +T.finish('mod supports') From 044ff9205ef332b9d54b3ce7dc2ebad7e5312bb8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:39:17 +0200 Subject: [PATCH 02/71] Add opt-in unified mod content records across generations --- docs/modding.md | 7 + docs/unified-mod-content.md | 164 ++++++++++ src/mods/Loader.lua | 14 + src/mods/Manifest.lua | 5 + src/mods/Registry.lua | 23 +- src/mods/Schemas.lua | 5 +- src/mods/UnifiedContent.lua | 518 +++++++++++++++++++++++++++++++ src/mods/UnifiedIds.lua | 85 +++++ src/mods/UnifiedSchemas.lua | 60 ++++ tests/engine/unified_content.lua | 470 ++++++++++++++++++++++++++++ 10 files changed, 1345 insertions(+), 6 deletions(-) create mode 100644 docs/unified-mod-content.md create mode 100644 src/mods/UnifiedContent.lua create mode 100644 src/mods/UnifiedIds.lua create mode 100644 src/mods/UnifiedSchemas.lua create mode 100644 tests/engine/unified_content.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..2ea5c7f7e 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -277,3 +277,10 @@ The launcher hands the link to the operating system to open, so only `http://` and `https://` links are accepted. Any other value, such as a `file:` link or a custom scheme, is dropped with a warning. The mod itself still loads; it just gets no button. + +## Unified content records (opt-in) + +API-2 mods can select `"content_format": "unified"` to read and edit common +Pokémon, move, item, trainer and encounter definition shapes. Existing mods +keep native formats. See [the format, supported operations and limitations](unified-mod-content.md) +for the manifest guard, generation coverage, symbolic IDs and failure behavior. diff --git a/docs/unified-mod-content.md b/docs/unified-mod-content.md new file mode 100644 index 000000000..64d83da74 --- /dev/null +++ b/docs/unified-mod-content.md @@ -0,0 +1,164 @@ +# Unified definition records (opt-in) + +A mod can use the same core content records when modifying different game +versions. Enable the format explicitly in an API-2 manifest: + +```json +{ + "id": "portable_balance", + "name": "Portable Balance", + "version": "1.0.0", + "api": 2, + "content_format": "unified", + "generations": [1, 2, 3, 4, 5], + "entry": "main.lua" +} +``` + +```lua +return function(mod) + -- Required when distributing to older engine builds: those builds do not + -- understand content_format and would otherwise supply native records. + assert(mod.contentFormat == "unified", "This mod needs unified content support") + if mod.content.moves:has("TACKLE") then + local ok, reason = mod.content.moves:patch("TACKLE", { power = 45 }) + assert(ok, reason) + end +end +``` + +Omitting the field (or using `"native"`) preserves existing mod behavior. +API 1 cannot opt in. Mod API stays at **2**: this is the content-record part of +portability, not Gen1Recomp's complete API 3. Callback payloads, saved Pokémon, +world/battle services, scripts and other registries retain their native forms. +A mod still needs testing on every game it claims. + +## Available records + +The five handles retain `register`, `override`, `patch`, `remove`, `get` and +`each`, and add `has`, `supports()` and `available`. Other content handles pass +through unchanged. `mod.assets` retains its existing content-handle aliases. +On engines that provide `mod:supports`, content queries follow the selected +format's availability; other capability surfaces retain their existing answers. + +| Handle | Unified fields | Supported versions | +| --- | --- | --- | +| `pokemon` | `id`, `name`, `dex`, `types`, `baseStats`, `catchRate`, `baseExp`, `growthRate`, `level1Moves`, `learnset`, `evolutions`, `tmhm`; optional breeding/art fields | Existing definitions, Gen 1–5 | +| `moves` | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `effect`, `category`, `priority`, `description` | Existing definitions, Gen 1–5 | +| `items` | `id`, `name`, `price`, `machine`, `effect`, `ball`, `tossable`, `needsTarget`, `description` | Existing definitions, Gen 1–5 | +| `trainers` | `id`, `name`, `pic`, `baseMoney`, `battleTheme`, `partyNames`, `parties` | Existing definitions, Gen 1–5 | +| `encounters` | `id`, `grass`, `water` | Gen 1–3 grass/water tables | + +Reads and iteration return detached records. Editing a returned table does +nothing until a write method is called. Each record also contains a detached, +**read-only** `native` snapshot for diagnostics. Unexposed native fields are +preserved by edits; changing that snapshot is refused. `override` requires a +complete unified record and preserves omitted optional/native extension fields; +use `mod.DELETE` to clear a represented optional field. For normal +edits use `patch`; this keeps earlier and later mods' independent fields in the +registry's normal operation order. Writes return `true` on success. + +`register` supports string-key definitions on Gen 1–3, except new Gen 3 species: +their EV/ability/storage metadata has no canonical constructor here. Numeric +DS allocations are refused. This layer does not invent storage slots or +metadata. Use native content for those additions. Existing records can be +patched, overridden and removed across all five generations. + +### IDs and references + +Species, move and item IDs are uppercase symbolic strings, such as `PIKACHU`, +`TACKLE` and `POTION`. Resolution uses the selected dataset's native keys and +names, not fixed cartridge numbers. `FARFETCHD`, `MR_MIME`, `NIDORAN_F` and +`NIDORAN_M` retain distinct names. `TM01`/`HM01` names normalize to +`TM_01`/`HM_01`. Types use Gen 1 spellings, including `PSYCHIC_TYPE`; the native GBA abbreviations `PSYCHC`, +`FIGHT` and `ELECTR` map to their common names. + +A numeric table or a generated key such as `SPECIES_025` is mapped through its +name in that ROM. Renaming a definition through a mod does not change its ID +within that load. Numeric/string aliases of one record are read once and +written or removed together. Multiple distinct records with one canonical +name are ambiguous: the adapter refuses to choose one. Missing or ambiguous +`get` returns `nil, reason`; `has` returns false; ambiguous iteration raises. + +Trainer and encounter IDs remain game-specific; no cross-game equivalence +between a trainer class, a numbered individual or two maps is implied. +Numeric trainer IDs are exposed as decimal strings. References must resolve +when a write is made: define a referenced record first or declare a mod +dependency that provides it. Missing references return `nil, reason` before +an operation is appended. Removing a referenced definition retains the +loader's post-merge, owner-attributed reference diagnostics, including DS +numeric targets and Gen 2 time tables. + +### Pokémon and trainers + +`baseStats` uses `hp`, `attack`, `defense`, `speed`, `special`, with optional +`specialAttack` and `specialDefense` for the later split. A patch to `special` +sets both halves on Gen 2–5; explicit split fields can change them separately. +Conflicting values are rejected. Gen 1 refuses unequal Special stats. + +`level1Moves` is a list of move IDs. `learnset` is +`{ { level = 9, move = "TACKLE" } }`. Evolutions use +`{ method = "LEVEL", level = 20, species = "RAICHU" }`, or `ITEM`, `TRADE` +and `FRIENDSHIP` with `item`, `time` (`ANYTIME`, `MORNDAY`, `NITE`) or +`comparison` (`ATK_GT_DEF`, `ATK_LT_DEF`, `ATK_EQ_DEF`) where representable. +Held trade items and friendship/stat conditions are translated for Gen 2–3. +DS level evolutions are translated to their numeric target and parameter. +Other DS evolution writes and unknown method changes are refused; unchanged +native methods survive a detached read/modify/write. Gen 1 refuses eggs, +breeding fields, held items and later evolution conditions. + +Trainer `parties` always has the form +`{ { { species = "PIKACHU", level = 18, item = "POTION", moves = { "TACKLE" } } } }`. +Items and moves are optional, and held items require Gen 2 or later. Native +per-slot IVs, flags and AI metadata are retained. A DS trainer's single `party` +and `parties[1]` aliases stay synchronized. + +Move accuracy is percent, as in the engine's existing normalized definitions. +Native effect selectors and item behavior remain opaque: changing a move's +effect on Gen 2–5, changing later item behavior, or changing a DS move type +without a native type adapter returns `nil, reason`. This avoids changing a +symbolic field while leaving an incompatible native selector behind. Power, +accuracy, PP, category, price and other represented fields can be edited. + +### Encounters + +An area is `{ rate, slots = { { species = "PIKACHU", level = 5 } } }`. +A range uses `minLevel` and `maxLevel` instead of `level`. Rates are fractions +out of **256**, including fractional values needed to represent GBA rates +exactly. Conversion refuses a rate that cannot be represented in native units; +it never silently rounds. Slot edits retain the native count and probability +buckets. Changing slot counts or weights requires native content. + +Gen 2 also exposes `grass.times.MORN`, `DAY` and `NITE` in the same area shape. +A shared rate/slot patch applies to every native time. An explicitly changed +time can override that patch. Unchanged inherited times in a detached record +do not undo a shared edit. + +DS encounter tables carry daily, seasonal, swarm and other replacements. +This adapter reports `available = false` and `supports()` returns `false, +reason` for them; reads/writes return `nil, reason` and iteration is empty. +The native encounter API remains available to mods using the native format. + +### Lists, failures and lifecycle + +Lists replace; `__append` and `__prepend` extend them. `{}` and `mod.DELETE` +clear represented lists, including inherited learnsets and TM lists. Required +scalar fields, base stats and species types cannot be deleted. Other optional +fields support `mod.DELETE` where their native representation permits it. + +Malformed records raise schema errors. Non-representable operations return +`nil, reason` before recording anything. Payloads, returned records and native +snapshots never share writable tables with the operation log. Frozen content +still rejects writes. An entry failure rolls back every operation owned by +that mod, including all aliases. No Game, save, graphics or imported ROM is +needed to query or construct the content facade. + +## Validation + +Run `luajit tests/engine/unified_content.lua`. The tests load synthetic datasets +through the real loader, exercise all registered host versions and compare +native and unified round trips. Behavioral checks use native Pokémon/trainer +construction, encounter selection, DS move parsing and evolution dispatch. +This proves the exercised formats and consumers, not real-ROM playthroughs or +visual correctness. The complete local suite and its base comparison are +reported in the pull request. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..107f6041b 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1020,6 +1020,10 @@ function Loader:_api(mod) api.content[alias] = self:_contentApi(mod, self.content[canonical], ("the %s registry is deprecated; use %s"):format(alias, canonical)) end + api.contentFormat = mod.manifest.content_format or "native" + if api.contentFormat == "unified" then + api.content = engineRequire("src.mods.UnifiedContent").new(loader, mod, api.content, hostGen) + end -- assets keeps the v1 alias to the content accessors and adds the file -- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve -- A RELATIVE PATH STAYS RELATIVE. @@ -1142,6 +1146,16 @@ function Loader:_api(mod) world = module.new(game, modId) return world end }) + -- When capability queries are installed, report the selected content + -- format's actual availability. Other surfaces retain their host query. + if api.contentFormat == "unified" and api.supports then + local supports, content = api.supports, api.content + api.supports = function(self, surface, name) + local handle = type(name) == "string" and content[name] + if surface == "content" and handle and handle.supports then return handle:supports() end + return supports(self, surface, name) + end + end return api end diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..2fbc8602b 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -223,6 +223,10 @@ function Manifest.validate(raw, path) assert(api <= Version.modApi, ("requires mod API %d; this engine provides %d") :format(api, Version.modApi)) local strict = api >= 2 + assert(raw.content_format == nil or raw.content_format == "native" + or raw.content_format == "unified", "content_format must be native or unified") + assert(raw.content_format ~= "unified" or api >= 2, + "unified content requires mod API 2") local profile = raw.profile or "content" if not Manifest.PROFILES[profile] then @@ -305,6 +309,7 @@ function Manifest.validate(raw, path) version = raw.version, entry = raw.entry, api = api, + content_format = raw.content_format or "native", priority = tonumber(raw.priority) or 0, dependencies = array(raw.dependencies), optional_dependencies = array(raw.optional_dependencies), diff --git a/src/mods/Registry.lua b/src/mods/Registry.lua index 9719673ef..cf200f844 100644 --- a/src/mods/Registry.lua +++ b/src/mods/Registry.lua @@ -28,11 +28,16 @@ function Registry.new(name, spec) }, Registry) end +local function validId(self, id) + return type(id) == "string" and id ~= "" + or self.numericIds and type(id) == "number" and id >= 1 and id < math.huge and id % 1 == 0 +end + local function append(self, id, op, value, owner) if self.frozen then error(self.name .. ": content is frozen after load") end - assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assert(validId(self, id), self.name .. " id is required") local list = self.ops[id] if not list then list = {} @@ -94,6 +99,14 @@ local function fold(self, value, opList, id) if type(entry.value) == "table" then value = Merge.deepMerge(Merge.deepCopy(value == nil and {} or value), entry.value, self.spec.semantics) + -- Adapter-produced list patches include empty replacements. Native + -- patch semantics keep {} as a dictionary/no-op, so this internal + -- path list explicitly distinguishes clearing a list from omitting it. + for _, path in ipairs(entry.replaceLists or {}) do + local into, from = value, entry.value + for i = 1, #path - 1 do into, from = into[path[i]], from[path[i]] end + into[path[#path]] = Merge.deepCopy(from[path[#path]]) + end else value = entry.value end @@ -120,12 +133,14 @@ function Registry:override(id, value, owner) return append(self, id, "override", value, owner) end -function Registry:patch(id, partial, owner) +function Registry:patch(id, partial, owner, replaceLists) assert(partial ~= nil, self.name .. " patch value is required for " .. tostring(id)) if self.spec.semantics == "compose" then error(self.name .. ": patch is not supported on compose registries") end - return append(self, id, "patch", partial, owner) + local result = append(self, id, "patch", partial, owner) + if replaceLists then self.ops[id][#self.ops[id]].replaceLists = Merge.deepCopy(replaceLists) end + return result end -- tombstone: consumers treat the id as absent after the merge @@ -196,7 +211,7 @@ end -- Works after the loader has frozen content, because it appends nothing -- the -- ops are untouched and only the reading of them changes. function Registry:preferOwner(id, owner) - assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assert(validId(self, id), self.name .. " id is required") self.prefer = self.prefer or {} self.prefer[id] = owner self.cache[id] = nil diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..d9e994c1a 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -268,13 +268,14 @@ end -- ------- cross-reference pass +local NUMERIC_REFS = { pokemon = true, moves = true, items = true, trainers = true } local collectRefs collectRefs = function(t, value, path, out) if value == nil or value == Merge.DELETE then return end local kind = t.kind if kind == "opt" then return collectRefs(t.inner, value, path, out) end if kind == "id" then - if type(value) == "string" then + if type(value) == "string" or NUMERIC_REFS[t.registry] and type(value) == "number" and value > 0 then out[#out + 1] = { registry = t.registry, ref = value, path = path } end return @@ -353,7 +354,7 @@ function Schemas.crossValidate(loader, data) end end for name, registry in pairs(loader.content) do - local spec = registry.spec + local spec = registry.referenceSpec or registry.spec for id in pairs(registry.ops) do local value = registry:get(id) if value ~= nil and registry.owners[id] ~= Schemas.ENGINE then diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua new file mode 100644 index 000000000..dde1870a8 --- /dev/null +++ b/src/mods/UnifiedContent.lua @@ -0,0 +1,518 @@ +-- Explicit opt-in definition-record boundary. Native registries and gameplay +-- stay authoritative; no save, callback, world or battle data is rewritten. +local Merge = require("src.mods.Merge") +local Schemas = require("src.mods.Schemas") +local Shapes = require("src.mods.UnifiedSchemas") +local Ids = require("src.mods.UnifiedIds") +local U = {} +local copy, DELETE = Merge.deepCopy, Merge.DELETE +local function unsupported(reason) error({ unsupported = reason }, 0) end +local function check(ok, reason) if not ok then error("invalid unified content: " .. reason, 0) end end +local function equal(a, b, seen) + if a == b then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false end + seen = seen or {}; if seen[a] == b then return true end; seen[a] = b + for k, v in pairs(a) do if not equal(v, b[k], seen) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end +local function finite(v, seen) + if type(v) == "number" then check(v == v and math.abs(v) < math.huge, "non-finite number") end + if type(v) ~= "table" or v == DELETE then return end + seen = seen or {}; check(not seen[v], "cyclic record"); seen[v] = true + for k, sub in pairs(v) do finite(k, seen); finite(sub, seen) end + seen[v] = nil +end +local function list(value, old, convert) + if value == DELETE then return {} end + check(type(value) == "table", "expected list") + if Merge.isWrapper(value) then + for k in pairs(value) do check(k == "__prepend" or k == "__append", "unknown list extension") end + local out = {} + for _, source in ipairs({ value.__prepend or {}, old or {}, value.__append or {} }) do + for _, row in ipairs(list(source, nil, function(v) return copy(v) end)) do out[#out + 1] = row end + end + return list(out, nil, convert) + end + local count = 0 + for k in pairs(value) do + check(type(k) == "number" and k % 1 == 0 and k >= 1 and k <= #value, "expected contiguous list") + count = count + 1 + end + check(count == #value, "expected contiguous list") + local out = {} + for i, row in ipairs(value) do out[i] = convert(row, i) end + return out +end +local function resolved(ctx, kind, id) + check(type(id) == "string" and id ~= "", "expected string " .. kind .. " ID") + local found, why = ctx.ids:resolve(kind, id) + if not found then unsupported(why) end + return found.key +end +local function readIds(ctx, kind, values) + local out = {} + for _, id in ipairs(values or {}) do + if id ~= 0 then out[#out + 1] = ctx.ids:read(kind, id) end + end + return out +end +local function writeIds(ctx, kind, values, old) + return list(values, old, function(id) return resolved(ctx, kind, id) end) +end +-- Gen 3's seven-byte names also produce these spellings (TypeChart's +-- registrant deliberately keeps them authoritative rather than renaming Data). +local TYPE_ALIASES = { PSYCHIC = "PSYCHIC_TYPE", PSYCHC = "PSYCHIC_TYPE", + MYSTERY = "CURSE_TYPE", FIGHT = "FIGHTING", ELECTR = "ELECTRIC" } +local function typeRead(id) return TYPE_ALIASES[id] or id end +local function typeWrite(ctx, id) + local registry = ctx.loader.content.type_chart + local found = registry:get(id) ~= nil and id or nil + for native, canonical in pairs(TYPE_ALIASES) do + if canonical == id and registry:get(native) ~= nil then + if found then unsupported("ambiguous type ID: " .. tostring(id)) end + found = native + end + end + if found then return found end + unsupported("unknown type ID: " .. tostring(id)) +end +local function reference(ctx, kind, id) + if ctx.loader.content[kind]:get(id) == nil then unsupported("unknown " .. kind .. " ID: " .. tostring(id)) end + return id +end + +local EVO = { + level = "LEVEL", useItem = "ITEM", trade = "TRADE", tradeWithItem = "TRADE", + friendship = "FRIENDSHIP", friendshipDay = "FRIENDSHIP", friendshipNight = "FRIENDSHIP", + HAPPINESS = "FRIENDSHIP", STAT = "LEVEL", + levelAtkGreater = "LEVEL", levelAtkLess = "LEVEL", levelAtkEqual = "LEVEL", +} +local function evolutionRead(ctx, row) + local method = EVO[row.method] or row.method + local out = { method = method, species = ctx.ids:read("pokemon", row.species or row.target) } + if method == "LEVEL" then out.level = row.level or row.param end + if method == "ITEM" then out.item = ctx.ids:read("items", row.item or row.param) end + if method == "TRADE" then out.item = ctx.ids:read("items", row.heldItem or (row.method == "tradeWithItem" and row.param)) end + if method == "FRIENDSHIP" then + out.time = row.timeOfDay or (row.method == "friendshipDay" and "MORNDAY") + or (row.method == "friendshipNight" and "NITE") + end + out.comparison = row.compare or ({ levelAtkGreater = "ATK_GT_DEF", levelAtkLess = "ATK_LT_DEF", + levelAtkEqual = "ATK_EQ_DEF" })[row.method] + return out +end +local function evolutionWrite(ctx, row, old) + if old and equal(row, evolutionRead(ctx, old)) then return copy(old) end + local method = row.method + if method ~= "LEVEL" and method ~= "ITEM" and method ~= "TRADE" and method ~= "FRIENDSHIP" then + unsupported("evolution method has no unified writer: " .. tostring(method)) + end + if ctx.gen == 1 and (method == "FRIENDSHIP" or row.item and method == "TRADE" + or row.time or row.comparison) then unsupported("evolution condition has no Gen 1 representation") end + if ctx.gen >= 4 then + -- This engine implements the native lowercase level method. Other DS + -- evolution conditions retain cartridge parameters and lack a safe writer. + if method ~= "LEVEL" or row.comparison then unsupported("DS evolution condition has no unified writer") end + check(row.level ~= nil and row.item == nil and row.time == nil, "invalid LEVEL condition") + return { method = "level", param = row.level, paramKind = "level", methodId = 4, + target = resolved(ctx, "pokemon", row.species) } + end + local out = { method = method, species = resolved(ctx, "pokemon", row.species) } + if method == "LEVEL" then + check(row.level ~= nil and row.item == nil and row.time == nil, "invalid LEVEL condition") + out.level = row.level + if row.comparison then out.method, out.compare = "STAT", row.comparison end + elseif method == "ITEM" then + check(row.item ~= nil and row.level == nil and row.time == nil and row.comparison == nil, "invalid ITEM condition") + out.item = resolved(ctx, "items", row.item) + elseif method == "TRADE" then + check(row.level == nil and row.time == nil and row.comparison == nil, "invalid TRADE condition") + if row.item then out.heldItem = resolved(ctx, "items", row.item) end + else + check(row.level == nil and row.item == nil and row.comparison == nil, "invalid FRIENDSHIP condition") + out.method, out.timeOfDay = "HAPPINESS", row.time ~= "ANYTIME" and row.time or nil + end + return out +end + +local readRecord +readRecord = function(ctx, kind, id, raw) + local out = { id = id, native = copy(raw) } + for key in pairs(Shapes[kind].fields) do + if key ~= "native" and key ~= "id" then out[key] = copy(raw[key]) end + end + if kind == "pokemon" then + local stats = raw.baseStats or {} + out.baseStats = { hp = stats.hp, attack = stats.attack, defense = stats.defense, speed = stats.speed, + special = stats.special or stats.spatk or stats.spAttack, + specialAttack = ctx.gen > 1 and (stats.spatk or stats.spAttack) or nil, + specialDefense = ctx.gen > 1 and (stats.spdef or stats.spDefense) or nil } + out.types = {}; for i, value in ipairs(raw.types or {}) do out.types[i] = typeRead(value) end + for _, key in ipairs({ "level1Moves", "tmhm", "eggMoves" }) do + if raw[key] then out[key] = readIds(ctx, "moves", raw[key]) end + end + out.level1Moves, out.learnset, out.evolutions = out.level1Moves or {}, {}, {} + for i, row in ipairs(raw.learnset or {}) do + out.learnset[i] = { level = row.level, move = ctx.ids:read("moves", row.move) } + end + for i, row in ipairs(raw.evolutions or {}) do out.evolutions[i] = evolutionRead(ctx, row) end + elseif kind == "moves" then + out.type = typeRead(raw.type) + out.category = raw.category or raw.class + if type(raw.effect) ~= "string" then out.effect = nil end + elseif kind == "items" then + if type(raw.effect) ~= "string" then out.effect = nil end + if raw.machine then out.machine.move = ctx.ids:read("moves", raw.machine.move) end + elseif kind == "trainers" then + out.parties = {} + for i, party in ipairs(raw.parties or (raw.party and { raw.party }) or {}) do + out.parties[i] = {} + for j, mon in ipairs(party) do + out.parties[i][j] = { species = ctx.ids:read("pokemon", mon.species), level = mon.level, + item = ctx.ids:read("items", mon.item), moves = mon.moves and readIds(ctx, "moves", mon.moves) } + end + end + elseif kind == "encounters" then + local function areaRead(area) + if not area then return nil end + local row = { rate = (area.rate or 0) * 256 / (area.rateMax or 256), slots = {} } + for i, slot in ipairs(area.slots or {}) do + row.slots[i] = { species = ctx.ids:read("pokemon", slot.species), level = slot.level, + minLevel = slot.min, maxLevel = slot.max } + end + if area.byTime then + row.times = { DAY = { rate = row.rate, slots = copy(row.slots) } } + for key, native in pairs({ MORN = "morn", NITE = "nite" }) do + if area.byTime[native] then row.times[key] = areaRead(area.byTime[native]) end + end + end + return row + end + out.grass, out.water = areaRead(raw.grass), areaRead(raw.water) + end + return out +end + +local function writeRecord(ctx, kind, id, value, old, mode) + local previous = old and readRecord(ctx, kind, id, old) or {} + if value.native ~= nil and not equal(value.native, old or {}) then unsupported("native diagnostics are read-only") end + if value.id ~= nil then check(value.id == id, "record id differs from operation id") end + if value.native ~= nil and old then + -- Detached read/modify/write records carry inherited fields. Convert only + -- actual edits, so round trips retain native selectors and absent aliases. + value = copy(value) + for key, v in pairs(value) do + if key ~= "id" and key ~= "native" and equal(v, previous[key]) then value[key] = nil end + end + end + local out, replacements = {}, {} + local function replace(key, v) out[key] = v; replacements[#replacements + 1] = { key } end + for key, v in pairs(value) do + if key ~= "native" and key ~= "id" then out[key] = copy(v) end + end + if not old then out.id = id end + if kind == "pokemon" then + if value.baseStats and value.baseStats ~= DELETE then + local stats, native = value.baseStats, {} + for _, stat in pairs(stats) do check(stat ~= DELETE, "cannot delete a native base stat") end + for _, key in ipairs({ "hp", "attack", "defense", "speed" }) do native[key] = stats[key] end + local a, b = stats.specialAttack, stats.specialDefense + if ctx.gen == 1 then + a, b = a or stats.special or (previous.baseStats or {}).special, + b or stats.special or (previous.baseStats or {}).special + if (a or b) and a ~= b then unsupported("Gen 1 cannot split Special") end + if stats.special and a ~= stats.special then check(false, "conflicting Special values") end + native.special = stats.special or stats.specialAttack or stats.specialDefense + else + -- A patch to special intentionally writes both halves. On detached + -- read/modify/write records, unchanged split fields are inherited. + if stats.special ~= nil and (value.native == nil or stats.special ~= (previous.baseStats or {}).special) then + if value.native ~= nil then + if a == (previous.baseStats or {}).specialAttack then a = nil end + if b == (previous.baseStats or {}).specialDefense then b = nil end + end + check(a == nil or a == stats.special, "conflicting Special Attack values") + a, b = a or stats.special, b or stats.special + elseif stats.special ~= nil and not old then a, b = a or stats.special, b or stats.special end + native.spatk, native.spdef = a, b + if a ~= nil then native.special = a end + if old and old.baseStats and old.baseStats.spAttack ~= nil then native.spAttack = a end + if old and old.baseStats and old.baseStats.spDefense ~= nil then native.spDefense = b end + end + out.baseStats = native + end + check(value.types ~= DELETE, "species types cannot be deleted") + if value.types then + local types = list(value.types, previous.types, function(t) return typeWrite(ctx, t) end) + check(#types >= 1 and #types <= 2, "species needs one or two types") + replace("types", types) + out.type1, out.type2 = types[1], types[2] or types[1] + end + for _, key in ipairs({ "level1Moves", "tmhm", "eggMoves" }) do + if value[key] ~= nil then replace(key, writeIds(ctx, "moves", value[key], previous[key])) end + end + if value.learnset ~= nil then + replace("learnset", list(value.learnset, previous.learnset, function(row) + return { level = row.level, move = resolved(ctx, "moves", row.move) } + end)) + end + if value.evolutions ~= nil then + replace("evolutions", list(value.evolutions, previous.evolutions, function(row, i) + return evolutionWrite(ctx, row, old and old.evolutions and old.evolutions[i]) + end)) + end + if value.growthRate and value.growthRate ~= DELETE then reference(ctx, "growth_rates", value.growthRate) end + if ctx.gen == 1 and (value.eggMoves or value.genderRatio or value.eggCycles) then + unsupported("breeding fields have no Gen 1 representation") + end + if not old and ctx.gen >= 3 then unsupported("new species require native EV/ability/storage metadata; patch an existing species") end + elseif kind == "moves" then + if value.type and value.type ~= DELETE then + out.type = typeWrite(ctx, value.type) + if old and old.typeId and out.type ~= old.type then unsupported("changing a DS type requires native type metadata") end + end + if value.effect and value.effect ~= DELETE then + reference(ctx, "move_effects", value.effect) + if ctx.gen > 1 and (not old or value.effect ~= old.effect) then unsupported("changing move effects requires a native effect adapter") end + end + if value.category ~= nil and old and old.class ~= nil then + check(value.category ~= DELETE, "native move class cannot be deleted") + out.class = value.category + if old.classId ~= nil then out.classId = ({ physical = 0, special = 1, status = 2 })[value.category] end + end + elseif kind == "items" then + if value.machine and value.machine ~= DELETE then + out.machine.move = resolved(ctx, "moves", value.machine.move) + end + for key, registry in pairs({ effect = "item_effects", ball = "balls" }) do + if value[key] and value[key] ~= DELETE then + reference(ctx, registry, value[key]) + if ctx.gen > 1 and (not old or value[key] ~= old[key]) then unsupported("changing item behavior requires native metadata") end + end + end + elseif kind == "trainers" and value.parties ~= nil then + local prior = old and (old.parties or (old.party and { old.party })) or {} + local parties = list(value.parties, previous.parties, function(party, i) + return list(party, nil, function(mon, j) + local native = copy(prior[i] and prior[i][j] or {}) + native.species, native.level = resolved(ctx, "pokemon", mon.species), mon.level + if ctx.gen == 1 and mon.item then unsupported("held items have no Gen 1 representation") end + native.item = mon.item and resolved(ctx, "items", mon.item) or nil + native.moves = mon.moves and writeIds(ctx, "moves", mon.moves) or nil + return native + end) + end) + replace("parties", parties) + if old and old.party then + check(#parties == 1, "native trainer has exactly one party") + replace("party", parties[1]) + end + elseif kind == "encounters" then + local function areaWrite(input, native, before, path) + if input == DELETE then return DELETE end + native, before = native or {}, before or {} + check(input.times ~= DELETE, "native time tables cannot be deleted") + if input.times then + input = copy(input) + if equal(input.rate, before.rate) then input.rate = nil end + if equal(input.slots, before.slots) then input.slots = nil end + end + local result = {} + if input.rate ~= nil then + check(input.rate ~= DELETE, "encounter rate cannot be deleted") + local rate = input.rate * (native.rateMax or 256) / 256 + if math.abs(rate - math.floor(rate + 0.5)) > 1e-8 then unsupported("encounter rate is not representable in native units") end + result.rate = math.floor(rate + 0.5) + end + if input.slots ~= nil then + local rows = list(input.slots, before.slots, function(slot, i) + local row = copy((native.slots or {})[i] or {}) + row.species = resolved(ctx, "pokemon", slot.species) + if slot.level then + check(slot.minLevel == nil and slot.maxLevel == nil, "conflicting encounter levels") + row.level, row.min, row.max = slot.level, nil, nil + else + check(slot.minLevel ~= nil and slot.maxLevel ~= nil and slot.minLevel <= slot.maxLevel, "invalid encounter level range") + row.level, row.min, row.max = nil, slot.minLevel, slot.maxLevel + end + if slot.chance then unsupported("changing encounter weights requires native buckets") end + return row + end) + if #rows ~= #(native.slots or {}) then unsupported("changing slot count requires native probability buckets") end + result.slots = rows; local p = copy(path); p[#p + 1] = "slots"; replacements[#replacements + 1] = p + end + if input.times then check(ctx.gen == 2 and native.byTime ~= nil, "time-specific encounters require a native time table") end + if native.byTime then + result.byTime = {} + for time, nativeTime in pairs({ MORN = "morn", DAY = "day", NITE = "nite" }) do + local shared = { rate = input.rate, slots = input.slots } + -- A full detached record contains inherited times: only explicit + -- changes override a shared rate/slot patch. + local specific = input.times and input.times[time] + if specific and before.times and equal(specific, before.times[time]) then specific = nil end + if specific then + for k, v in pairs(specific) do + if not before.times or not before.times[time] or not equal(v, before.times[time][k]) then shared[k] = v end + end + end + if nativeTime == "day" then + local d = areaWrite(shared, { rate = native.rate, rateMax = native.rateMax, slots = native.slots }, before, path) + if d.rate ~= nil then result.rate = d.rate end + if d.slots then result.slots = d.slots end + elseif native.byTime[nativeTime] then + local p = copy(path); p[#p + 1] = "byTime"; p[#p + 1] = nativeTime + result.byTime[nativeTime] = areaWrite(shared, native.byTime[nativeTime], before.times and before.times[time], p) + end + end + end + return result + end + for _, key in ipairs({ "grass", "water" }) do + if value[key] ~= nil then out[key] = areaWrite(value[key], old and old[key], previous[key], { key }) end + end + end + return out, replacements +end + +local function supported(kind, gen) + if kind == "encounters" and gen >= 4 then + return false, "unsupported: DS encounters have seasonal/daily replacement tables; use native content" + end + return true +end +local function protect(fn) + local result = { pcall(fn) } + if not result[1] then + if type(result[2]) == "table" and result[2].unsupported then return nil, "unsupported: " .. result[2].unsupported end + error(result[2], 0) + end + return unpack(result, 2) +end +function U.new(loader, mod, native, gen) + local out = {}; for name, handle in pairs(native) do out[name] = handle end + for kind, shape in pairs(Shapes) do + local registry = loader.content[kind] + if gen >= 4 and kind ~= "encounters" then registry.numericIds = true end + local f = Schemas.f + registry.referenceSpec = copy(registry.spec) + if kind == "pokemon" then + registry.referenceSpec.fields.evolutions = f.list(f.rec { + species = f.opt(f.id("pokemon")), target = f.opt(f.id("pokemon")), + item = f.opt(f.id("items")), heldItem = f.opt(f.id("items")), + }) + elseif kind == "trainers" then + local parties = f.list(f.list(f.rec { species = f.id("pokemon"), + item = f.opt(f.id("items")), moves = f.opt(f.list(f.id("moves"))) })) + registry.referenceSpec.fields.parties = parties + registry.referenceSpec.fields.party = parties.inner + elseif kind == "encounters" then + local slots = f.list(f.rec { species = f.id("pokemon") }) + local area = f.rec { slots = slots, byTime = f.opt(f.map(f.str, f.rec { slots = slots })) } + registry.referenceSpec.fields.grass, registry.referenceSpec.fields.water = area, area + if gen >= 4 then + -- The DS writer is unavailable, but removing a numeric species must + -- still see native encounter references, including seasonal tables. + local fields = {} + for _, key in ipairs({ "grass", "darkGrass", "rustling" }) do fields[key] = slots end + for _, key in ipairs({ "surf", "oldRod", "goodRod", "superRod", "ripplingSurf", "ripplingFishing" }) do + fields[key] = f.rec { slots = slots } + end + fields.timed = f.map(f.str, f.list(f.id("pokemon"))) + fields.radar = f.list(f.id("pokemon")) + fields.dualSlot = f.map(f.str, f.list(f.id("pokemon"))) + fields.swarm = f.union { f.list(f.id("pokemon")), f.map(f.str, f.id("pokemon")) } + fields.seasons = f.map(f.str, f.rec(fields)) + registry.referenceSpec.fields = fields + end + end + local available, why = supported(kind, gen) + local function context() return { loader = loader, gen = gen, ids = Ids.new(loader) } end + local function read(id) + if not available then return nil, why end + local ctx = context() + local found, err = ctx.ids:resolve(kind, id) + if not found then return nil, err end + local raw = registry:get(found.key) + return raw and readRecord(ctx, kind, id, raw) or nil + end + local handle = { available = available, supports = function() return available, why end, + get = function(_, id) return read(id) end, + has = function(_, id) return read(id) ~= nil end, + each = function() + if not available then return function() end, why end + local ctx, keys = context(), {} + for id, found in pairs(ctx.ids:entries(kind)) do + if found == false then error("ambiguous " .. kind .. " ID: " .. id, 0) end + keys[#keys + 1] = id + end + table.sort(keys) + local i = 0 + return function() + i = i + 1; local id = keys[i] + if id == nil then return end + local found = ctx.ids:resolve(kind, id) + return id, readRecord(ctx, kind, id, registry:get(found.key)) + end + end, + } + for _, verb in ipairs({ "register", "override", "patch", "remove" }) do + handle[verb] = function(_, id, value) + if not available then return nil, why end + return protect(function() + check(type(id) == "string" and id ~= "", "operation id must be a non-empty string") + local ctx = context() + local found, err = ctx.ids:resolve(kind, id) + if not found and err:match("^ambiguous") then unsupported(err) end + if not found then + if gen >= 4 then unsupported("new numeric records require native storage allocation") end + found = { key = id, keys = { id } } + end + local old = registry:get(found.key) + if verb == "register" then check(old == nil, kind .. " already registered: " .. id) end + if verb == "remove" then + check(not registry.frozen, kind .. ": content is frozen after load") + loader:_journal(kind) + for _, key in ipairs(found.keys) do registry:remove(key, mod.manifest.id) end + return true + end + finite(value) + check(type(value) == "table" and value ~= DELETE, "expected record") + for key in pairs(value) do check(shape.fields[key] ~= nil, "unknown " .. kind .. " field " .. tostring(key)) end + local function deletes(fields, record) + for key, entry in pairs(record) do + local field = fields[key] + if field and field.kind ~= "opt" then + check(entry ~= DELETE or field.kind == "list", "cannot delete required field " .. key) + if field.kind == "rec" and type(entry) == "table" then deletes(field.fields, entry) end + end + end + end + deletes(shape.fields, value) + local valid, validation = Schemas.check(shape, kind, id, value, verb) + check(valid, validation or "invalid record") + local payload, replacements = writeRecord(ctx, kind, id, value, old, verb) + if verb == "patch" and not old then unsupported("cannot patch absent " .. kind .. " ID: " .. id) end + check(not registry.frozen, kind .. ": content is frozen after load") + loader:_journal(kind) + for _, key in ipairs(found.keys) do + if verb == "patch" then registry:patch(key, payload, mod.manifest.id, replacements) + else + local record = Merge.deepMerge(copy(old or {}), payload) + for _, path in ipairs(replacements) do + local into, from = record, payload + for i = 1, #path - 1 do into, from = into[path[i]], from[path[i]] end + into[path[#path]] = copy(from[path[#path]]) + end + registry[verb](registry, key, record, mod.manifest.id) + end + end + return true + end) + end + end + out[kind] = handle + end + return out +end +return U diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/src/mods/UnifiedSchemas.lua b/src/mods/UnifiedSchemas.lua new file mode 100644 index 000000000..10d2f6bb2 --- /dev/null +++ b/src/mods/UnifiedSchemas.lua @@ -0,0 +1,60 @@ +-- The opt-in content format is separate from the legacy/native schemas. +-- These are definition records, not saved Pokemon or callback payloads. +local Schemas = require("src.mods.Schemas") +local f = Schemas.f +local id = f.str +local ids = f.list(id) +local level = f.int(1, 100) +local stats = f.rec { + hp = f.int(1, 255), attack = f.int(1, 255), defense = f.int(1, 255), + speed = f.int(1, 255), special = f.int(1, 255), + specialAttack = f.opt(f.int(1, 255)), specialDefense = f.opt(f.int(1, 255)), +} +local evolution = f.rec { + method = id, species = id, level = f.opt(level), item = f.opt(id), + time = f.opt(f.enum { "ANYTIME", "MORNDAY", "NITE" }), + comparison = f.opt(f.enum { "ATK_GT_DEF", "ATK_LT_DEF", "ATK_EQ_DEF" }), +} +local slot = f.rec { + species = id, level = f.opt(level), minLevel = f.opt(level), maxLevel = f.opt(level), + chance = f.opt(f.numRange(0, 100)), +} +local area = f.rec { rate = f.numRange(0, 256), slots = f.list(slot), + times = f.opt(f.map(f.enum { "MORN", "DAY", "NITE" }, + f.rec { rate = f.opt(f.numRange(0, 256)), slots = f.opt(f.list(slot)) })) } +local U = { + pokemon = { fields = { + id = id, name = f.str, dex = f.opt(f.int(1)), types = ids, baseStats = stats, + catchRate = f.int(0, 255), baseExp = f.int(0), growthRate = id, + level1Moves = ids, learnset = f.list(f.rec { level = level, move = id }), + evolutions = f.list(evolution), tmhm = f.opt(ids), eggMoves = f.opt(ids), + genderRatio = f.opt(f.int(0, 255)), eggCycles = f.opt(f.int(0, 255)), + spriteFront = f.opt(f.path), spriteBack = f.opt(f.path), frontSize = f.opt(f.int(1, 7)), + trueColor = f.opt(f.bool), + } }, + moves = { fields = { + id = id, name = f.str, type = id, power = f.int(0, 255), accuracy = f.int(0, 100), + pp = f.int(0, 64), effect = f.opt(id), + category = f.opt(f.enum { "physical", "special", "status" }), + priority = f.opt(f.int(-7, 7)), description = f.opt(f.str), + } }, + items = { fields = { + id = id, name = f.str, price = f.int(0), + machine = f.opt(f.rec { kind = f.str, move = id, number = f.int(0) }), + effect = f.opt(id), ball = f.opt(id), tossable = f.opt(f.bool), + needsTarget = f.opt(f.bool), description = f.opt(f.str), + } }, + trainers = { fields = { + id = id, name = f.str, pic = f.opt(f.path), baseMoney = f.opt(f.int(0)), + battleTheme = f.opt(id), partyNames = f.opt(f.list(f.str)), + parties = f.list(f.list(f.rec { + species = id, level = level, item = f.opt(id), moves = f.opt(ids), + })), + } }, + encounters = { fields = { id = f.opt(id), grass = f.opt(area), water = f.opt(area) } }, +} +for _, spec in pairs(U) do + spec.fields.native = f.opt(f.any) + spec.semantics = "record" +end +return U diff --git a/tests/engine/unified_content.lua b/tests/engine/unified_content.lua new file mode 100644 index 000000000..b7695caa9 --- /dev/null +++ b/tests/engine/unified_content.lua @@ -0,0 +1,470 @@ +-- Synthetic extractor-shaped datasets; all mod changes go through Loader. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Manifest = require("src.mods.Manifest") +local Merge = require("src.mods.Merge") +local DELETE = Merge.DELETE +local Registry = require("src.mods.Registry") +local Runtime = require("src.mods.Runtime") +local original = Version.get() +local function raises(fn, fragment) + return T.raises(fn, fragment, "rejects " .. fragment) +end +local versions = { "red", "crystal", "emerald", "platinum", "black" } +local function fixture(gen) + local ds = gen >= 4 + local keys = { mon = ds and 25 or gen == 2 and "SPECIES_025" or "PIKACHU", + into = ds and 26 or gen == 2 and "SPECIES_026" or "RAICHU", + move = ds and 33 or gen == 2 and "MOVE_033" or "TACKLE", + item = ds and 17 or gen == 2 and "ITEM_017" or "POTION", + trainer = ds and 7 or "OPP_PROBE" } + local stats = { hp = 35, attack = 55, defense = 40, speed = 90, special = 50 } + if gen > 1 then stats.spatk, stats.spdef = 50, 60 end + if ds then stats.spAttack, stats.spDefense = 50, 60 end + local mon = { id = keys.mon, name = "Pikachu", dex = 25, types = { "NORMAL" }, + baseStats = stats, catchRate = 190, baseExp = 82, growthRate = "MEDIUM_FAST", + level1Moves = { keys.move }, learnset = { { level = 9, move = keys.move } }, tmhm = { keys.move }, + evolutions = { ds and { method = "level", target = keys.into, param = 20, methodId = 4, paramKind = "level" } + or { method = "LEVEL", species = keys.into, level = 20 } }, + spriteFront = "synthetic-front.png", spriteBack = "synthetic-back.png", frontSize = 7, + customNative = { sentinel = "retained" } } + if gen >= 3 then mon.evYield = { speed = 2 }; mon.abilities = { "STATIC" } end + local into = Merge.deepCopy(mon); into.id, into.name, into.dex, into.evolutions = keys.into, "Raichu", 26, {} + local move = { id = keys.move, name = "Tackle", type = "NORMAL", power = 35, accuracy = 95, pp = 35, + effect = "NO_ADDITIONAL_EFFECT", priority = 0, untouchedTarget = 6 } + if ds then move.class, move.classId, move.typeId, move.gen4Effect = "physical", 0, 0, 0 end + local item = { id = keys.item, name = "Potion", price = 300, tossable = true, heldEffect = 27 } + local party = { { species = keys.mon, level = 12, item = gen > 1 and keys.item or nil, + moves = { keys.move }, customIV = 31 } } + local trainer = { id = keys.trainer, name = "Probe", parties = { party }, baseMoney = 10, aiMods = { 9 } } + if ds then trainer.party = party; trainer.nativeFlag = 17 end + local grass = { rate = 20, slots = { { species = keys.mon, level = 5 }, { species = keys.into, level = 7 } }, + buckets = { 128, 256 } } + if gen == 2 then + grass.byTime = { morn = Merge.deepCopy(grass), nite = Merge.deepCopy(grass) } + grass.byTime.nite.rate = 10 + elseif gen == 3 then + grass.rateMax, grass.bucketSpan, grass.buckets = 180, 100, { 50, 100 } + grass.slots[1].level, grass.slots[1].min, grass.slots[1].max = nil, 4, 6 + end + local data = { pokemon = { [keys.mon] = mon, [keys.into] = into }, moves = { [keys.move] = move }, + items = { [keys.item] = item }, trainers = { [keys.trainer] = trainer }, + encounters = { ROUTE_PROBE = { grass = grass } }, constants = { generation = gen }, audio = {} } + if ds then + data.items.ITEM_017 = item + data.encounters = { [1] = { grassRate = 20, grass = { { species = keys.mon, level = 5, chance = 100 } } } } + end + return data, keys +end +local function run(gen, body, format, extra, supplied, selected) + Version.set(selected or versions[gen]) + local data, keys = fixture(gen) + data = supplied or data + local mods = { probe = { entry = body, format = format or "unified" } } + for id, spec in pairs(extra or {}) do mods[id] = spec end + local loader + local fs = { + read = function(path) + local id = path:match("^mods/([^/]+)/manifest.json$") + local mod = id and mods[id] + if mod then return ('{"id":"%s","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua","content_format":"%s","priority":%d}') + :format(id, mod.format or "unified", mod.priority or 0) end + end, + getDirectoryItems = function(path) + if path ~= "mods" then return {} end + local out = {}; for id in pairs(mods) do out[#out + 1] = id end; return out + end, + getInfo = function(path) + if path:match("main.lua$") or path:match("manifest.json$") then return { type = "file" } end + if path == "mods" or path:match("^mods/[^/]+$") then return { type = "directory" } end + end, + load = function(path) + local id = path:match("^mods/([^/]+)/main.lua$") + if mods[id] then return function(api) return mods[id].entry(api, loader) end end + end, + } + loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() error("content must not load Game") end + loader:load(data) + return loader.apis.probe, loader, data, keys +end +local function ok(value, reason) assert(value, reason) end +local function ops(registry) local n = 0; for _, rows in pairs(registry.ops) do n = n + #rows end; return n end +local function dataOnly(v) + if type(v) == "function" then return "" end + if type(v) ~= "table" then return v end + local out = {}; for k, sub in pairs(v) do out[k] = dataOnly(sub) end; return out +end + +-- Native defaults, manifest rejection and registry numeric-ID confinement. +raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 1, + content_format = "unified" }, "mods/bad") end, "unified content requires mod API 2") +raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 2, + content_format = "unifed" }, "mods/bad") end, "content_format must be native or unified") +raises(function() Registry.new("legacy"):patch(25, {}) end, "id is required") + +local savedLove = love; love = nil +for gen = 1, 5 do + local api, loader, data, keys = run(gen, function(mod) + T.eq(mod.contentFormat, "unified", "manifest selects unified content in entry") + if mod.supports then + T.eq(mod:supports("content", "encounters"), gen <= 3, "host query follows selected content format") + T.eq(mod:supports("hooks", "battle.damage"), true, "other capability surfaces retain host query") + end + local pokemon = assert(mod.content.pokemon:get("PIKACHU")) + T.eq(pokemon.id, "PIKACHU", "canonical species identity") + T.eq(pokemon.level1Moves[1], "TACKLE", "canonical starting move") + T.eq(pokemon.evolutions[1].species, "RAICHU", "canonical evolution target") + T.eq(pokemon.baseStats.special, 50, "canonical Special") + T.eq(pokemon.baseStats.specialDefense, gen > 1 and 60 or nil, "split Special retained") + pokemon.baseStats.hp = 99 + pokemon.native.customNative.sentinel = "local" + T.eq(mod.content.pokemon:get("PIKACHU").baseStats.hp, 35, "read is detached") + local _, why = mod.content.pokemon:patch("PIKACHU", pokemon) + T.check(why and why:find("read%-only"), "native diagnostics cannot be written") + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 70 } })) + ok(mod.content.moves:patch("TACKLE", { power = 45 })) + ok(mod.content.items:patch("POTION", { price = 100 })) + local trainerId = gen >= 4 and "7" or "OPP_PROBE" + local trainer = assert(mod.content.trainers:get(trainerId)) + T.eq(trainer.parties[1][1].species, "PIKACHU", "canonical trainer species") + T.eq(trainer.parties[1][1].moves[1], "TACKLE", "canonical trainer moves") + trainer.parties[1][1].level = 18 + ok(mod.content.trainers:patch(trainerId, { parties = trainer.parties })) + local _, whyMissing = mod.content.moves:patch("ABSENT", { power = 5 }) + T.check(whyMissing ~= nil, "absent patch declines with reason") + local encounter = mod.content.encounters + if gen <= 3 then + T.check(encounter:supports(), "GB/GBA encounter adapter available") + local row = assert(encounter:get("ROUTE_PROBE")) + row.grass.slots[1].species = "RAICHU" + ok(encounter:patch("ROUTE_PROBE", { grass = { slots = row.grass.slots } })) + else + local available, why = encounter:supports() + T.check(available == false and type(why) == "string", "DS encounters explicitly unsupported") + local result, err = encounter:patch("1", { grass = { rate = 10 } }) + T.check(result == nil and type(err) == "string", "DS encounter writes decline safely") + end + end) + T.eq(loader.mods.probe.state, "loaded", versions[gen] .. ": real entry/merge succeeds") + T.eq(#loader.errors, 0, versions[gen] .. ": no cross-reference errors") + T.eq(data.pokemon[keys.mon].baseStats.special, 70, "native Special alias updated") + T.eq(data.pokemon[keys.mon].baseStats.spdef, gen > 1 and 70 or nil, "native Special Defense updated") + T.eq(data.pokemon[keys.mon].customNative.sentinel, "retained", "native extensions survive") + T.eq(data.moves[keys.move].power, 45, "native move power updated") + T.eq(data.moves[keys.move].untouchedTarget, 6, "native move targeting preserved") + T.eq(data.items[keys.item].price, 100, "native item price updated") + T.eq(data.items[keys.item].heldEffect, 27, "native held effect preserved") + T.eq(data.trainers[keys.trainer].parties[1][1].level, 18, "native trainer party updated") + T.eq(data.trainers[keys.trainer].parties[1][1].customIV, 31, "native trainer metadata preserved") + T.eq(api.content.pokemon:get("PIKACHU").baseStats.special, 70, "read works after merge/freeze") + if gen >= 4 then + T.eq(data.items.ITEM_017.price, 100, "DS item string alias updated together with numeric ID") + T.eq(api.content.items:get("POTION").price, 100, "DS alias identity survives merge") + T.same(data.trainers[keys.trainer].party, data.trainers[keys.trainer].parties[1], "DS party aliases stay synchronized") + end + raises(function() api.content.moves:patch("TACKLE", { power = 99 }) end, "frozen") + local count = 0 + for id, row in api.content.items:each() do count = count + 1; T.eq(id, row.id, "iterator canonical ID") end + T.eq(count, 1, "iterator coalesces native aliases") +end +love = savedLove or require("tests.love_stub") + +-- No-op canonical round trips preserve every native field and native format. +for gen = 1, 5 do + local _, baseline = run(gen, function() end, "native") + local _, canonical, data = run(gen, function(mod) + local kinds = { "pokemon", "moves", "items", "trainers" } + if gen <= 3 then kinds[#kinds + 1] = "encounters" end + for _, kind in ipairs(kinds) do + for id, record in mod.content[kind]:each() do ok(mod.content[kind]:override(id, record)) end + end + end) + T.eq(canonical.mods.probe.state, "loaded", "round-trip load succeeds") + T.same(dataOnly(data), dataOnly(baseline.baseData), versions[gen] .. ": full native round-trip parity") +end + +-- Unsupported/malformed operations must append nothing and preserve input. +for gen = 1, 5 do + local _, loader = run(gen, function(mod) + local pokemon = mod.content.pokemon + local before = mod.content.pokemon:get("PIKACHU") + local invalid = { baseStats = { hp = 0 } } + raises(function() pokemon:patch("PIKACHU", invalid) end, "invalid") + T.eq(invalid.baseStats.hp, 0, "invalid caller payload untouched") + raises(function() pokemon:patch("PIKACHU", { base_stats = {} }) end, "unknown") + raises(function() pokemon:patch("PIKACHU", { baseStats = DELETE }) end, "required") + raises(function() pokemon:patch("PIKACHU", { learnset = { [2] = { level = 4, move = "TACKLE" } } }) end, "contiguous") + local cyclic = {}; cyclic.foo = cyclic + raises(function() pokemon:patch("PIKACHU", cyclic) end, "cyclic") + raises(function() mod.content.moves:patch("TACKLE", { power = 0 / 0 }) end, "non-finite") + local result, reason = pokemon:patch("PIKACHU", { level1Moves = { "MISSING" } }) + T.check(result == nil and reason:find("unknown moves ID", 1, true), "unknown cross-generation reference refuses") + T.same(pokemon:get("PIKACHU"), before, "failed operations leave effective record intact") + end) + T.eq(loader.mods.probe.state, "loaded", "caught refusals do not disable mod") + T.eq(ops(loader.content.pokemon), 0, "refused operations leave no Pokemon op log") +end + +-- Empty lists, append/prepend, DELETE and stacking with native mods. +local _, lists, data = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { learnset = {} })) + T.eq(#mod.content.pokemon:get("PIKACHU").learnset, 0, "empty list clears immediately") + ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __append = { { level = 10, move = "TACKLE" } } } })) + ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __prepend = { { level = 5, move = "TACKLE" } } } })) + ok(mod.content.pokemon:patch("PIKACHU", { tmhm = mod.DELETE })) +end, "unified", { + earlier = { format = "native", priority = -1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } }) + end }, + later = { format = "native", priority = 1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { defense = 75 } }) + end }, +}) +T.eq(lists.mods.probe.state, "loaded", "list and native stacking load") +T.eq(data.pokemon.SPECIES_025.learnset[1].level, 5, "prepend before append") +T.eq(data.pokemon.SPECIES_025.learnset[2].level, 10, "append retains order") +T.eq(#data.pokemon.SPECIES_025.tmhm, 0, "DELETE materializes empty native list") +T.eq(data.pokemon.SPECIES_025.baseStats.attack, 80, "earlier native patch preserved") +T.eq(data.pokemon.SPECIES_025.baseStats.defense, 75, "later native patch preserved") + +-- An entry failure rolls every canonical/native alias operation back. +for gen = 1, 5 do + local _, loader, data, keys = run(gen, function(mod) + ok(mod.content.items:patch("POTION", { price = 1 })) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 100 } })) + error("intentional entry failure") + end) + T.eq(loader.mods.probe.state, "failed", "entry failure attributed") + T.eq(data.items[keys.item].price, 300, "failed item edit rolled back") + T.eq(data.pokemon[keys.mon].baseStats.hp, 35, "failed species edit rolled back") + if gen >= 4 then T.eq(data.items.ITEM_017.price, 300, "failed DS alias edit rolled back") end +end + +-- Native consumers see the result, rather than merely passing schema checks. +local _, nativeLoader, data, keys = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 90, specialDefense = 95 } })) + ok(mod.content.trainers:patch("OPP_PROBE", { parties = { { { species = "RAICHU", level = 18, moves = { "TACKLE" } } } } })) + ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 32, + slots = { { species = "RAICHU", level = 8 }, { species = "PIKACHU", level = 9 } } } })) +end) +T.eq(nativeLoader.mods.probe.state, "loaded", "consumer probe loads") +local Pokemon = require("src.pokemon.Pokemon") +local mon = Pokemon.new(data, keys.mon, 10, function(a) return a end) +T.check(mon.stats.spatk ~= nil and mon.stats.spdef ~= nil, "native Pokemon constructor keeps split stat model") +local Encounter = require("src.world.Encounter") +for _, time in ipairs({ "MORNING", "DAY", "NIGHT" }) do + local tableAtTime = Encounter.atTime(data.encounters.ROUTE_PROBE.grass, time) + local encountered = Encounter.rollTable(tableAtTime, function(a) return a end) + T.eq(encountered.species, keys.into, time .. ": native encounter chooses translated species") + T.eq(encountered.level, 8, time .. ": native encounter uses translated level") +end +local Battle = require("src.battle.BattleState") +local game = { data = data, save = { options = {}, player = {}, party = {} } } +local party = Battle.buildTrainerParty(game, keys.trainer, 1, data.trainers[keys.trainer].parties[1]) +T.eq(party[1].species, keys.into, "native trainer builder receives translated species") +T.eq(party[1].level, 18, "native trainer builder receives translated level") + +-- Every registered version selects the same format using its own host +-- generation. The facade captures that generation before entry execution. +for _, version in ipairs(Version.ORDER) do + local gen = Version.generation(version) + local _, loader, data, key = run(gen, function(mod) + T.eq(mod.gameVersion, version, "selected host identity: " .. version) + mod.generation, mod.host.generation = 99, 99 + Version.set("red") + ok(mod.content.moves:patch("TACKLE", { power = 42 })) + end, "unified", nil, nil, version) + T.eq(loader.mods.probe.state, "loaded", version .. ": captured host format works") + T.eq(data.moves[key.move].power, 42, version .. ": same portable patch reaches native data") +end + +-- Canonical IDs are based on this ROM's names, not official numeric IDs. +local hacked = fixture(2) +hacked.pokemon.SPECIES_777 = hacked.pokemon.SPECIES_025 +hacked.pokemon.SPECIES_025 = nil +local _, hackLoader, hackData = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 65 } })) +end, "unified", nil, hacked) +T.eq(hackLoader.mods.probe.state, "loaded", "relocated ROM-hack species ID resolves") +T.eq(hackData.pokemon.SPECIES_777.baseStats.hp, 65, "no hard-coded National Dex lookup") + +local ambiguous = fixture(2) +ambiguous.pokemon.SPECIES_999 = Merge.deepCopy(ambiguous.pokemon.SPECIES_025) +local _, ambiguity = run(2, function(mod) + local value, why = mod.content.pokemon:get("PIKACHU") + T.check(value == nil and why:find("ambiguous", 1, true), "ambiguous name has a diagnostic") + local changed, reason = mod.content.pokemon:patch("PIKACHU", { catchRate = 1 }) + T.check(changed == nil and reason:find("ambiguous", 1, true), "ambiguous writes cannot choose a record") + raises(function() mod.content.pokemon:each() end, "ambiguous") +end, "unified", nil, ambiguous) +T.eq(ops(ambiguity.content.pokemon), 0, "ambiguity leaves no ops") + +-- New string-key records can be registered without changing legacy schema +-- behavior. Existing numeric records can be patched, not allocated blindly. +for gen = 1, 5 do + local _, loader, data = run(gen, function(mod) + local new = { id = "CUSTOM_ITEM", name = "Custom item", price = 42 } + local saved = Merge.deepCopy(new) + local changed, why = mod.content.items:register("CUSTOM_ITEM", new) + if gen < 4 then + T.check(changed == true, "string-key item registration succeeds") + T.eq(mod.content.items:get("CUSTOM_ITEM").price, 42, "registration immediately readable") + raises(function() mod.content.items:register("CUSTOM_ITEM", new) end, "already registered") + else T.check(changed == nil and why:find("storage", 1, true), "DS allocation declines without a native slot") end + T.same(new, saved, "registration input remains detached") + local payload = { price = 123 } + ok(mod.content.items:patch("POTION", payload)) + payload.price = 999 + T.eq(mod.content.items:get("POTION").price, 123, "later caller mutation cannot edit op log") + local record = assert(mod.content.pokemon:get("PIKACHU")) + local changed, reason = mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 99 } }) + if gen == 1 then + T.check(changed == nil and reason:find("split", 1, true), "Gen 1 split stat rejected atomically") + T.same(mod.content.pokemon:get("PIKACHU"), record, "rejected split retains native record") + else T.eq(changed, true, "later generation accepts split stat") end + raises(function() mod.content.pokemon:patch("PIKACHU", { types = mod.DELETE }) end, "cannot be deleted") + raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { specialDefense = mod.DELETE } }) end, "cannot delete") + end) + T.eq(loader.mods.probe.state, "loaded", "registration/refusal cases survive load") + T.eq(data.items.CUSTOM_ITEM and data.items.CUSTOM_ITEM.price, gen < 4 and 42 or nil, "only supported registrations reach Data") +end + +-- Probability units are uniform (a fraction out of 256) and lossless. +local _, rates, rateData = run(3, function(mod) + local before = mod.content.encounters:get("ROUTE_PROBE") + T.eq(before.grass.rate, 20 * 256 / 180, "GBA rate is projected into common units") + local changed, why = mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 31 } }) + T.check(changed == nil and why:find("representable", 1, true), "unrepresentable GBA rate rejected") + T.same(mod.content.encounters:get("ROUTE_PROBE"), before, "rejected rate leaves slots/weights intact") + ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 30 * 256 / 180 } })) +end) +T.eq(rateData.encounters.ROUTE_PROBE.grass.rate, 30, "representable rate maps exactly to native span") + +-- Native DS parser output is edited through the same facade. Its class +-- selector and numeric evolution target remain consistent for real consumers. +local parsed = fixture(4) +local bytes = { 0, 0, 0, 35, 0, 95, 35, 0, 0, 0, 0, 0, 0, 0, 0, 0 } +local chars = {}; for i, byte in ipairs(bytes) do chars[i] = string.char(byte) end +parsed.moves[33] = assert(require("src.import.Gen4Moves").parse(table.concat(chars))) +parsed.moves[33].id, parsed.moves[33].name = 33, "Tackle" +local _, parsedLoader, parsedData = run(4, function(mod) + ok(mod.content.moves:patch("TACKLE", { category = "special", power = 50 })) + ok(mod.content.pokemon:patch("PIKACHU", { evolutions = { { method = "LEVEL", level = 15, species = "RAICHU" } } })) +end, "unified", nil, parsed) +T.eq(parsedLoader.mods.probe.state, "loaded", "native parser-produced data accepts canonical edits") +T.eq(parsedData.moves[33].class, "special", "native DS class updated") +T.eq(parsedData.moves[33].classId, 1, "native DS class selector updated") +local Evolution = require("src.pokemon.Evolution") +local target = Evolution.pendingFor({ data = parsedData, save = { options = {} } }, + { species = 25, level = 15 }, { kind = "levelup" }) +T.eq(target, 26, "native evolution consumer follows translated numeric target") + +-- Removing a target retains post-merge reference checks, including numeric +-- trainer references and the otherwise hidden Gen 2 night slot table. +for _, gen in ipairs({ 2, 4 }) do + local _, loader = run(gen, function(mod) ok(mod.content.pokemon:remove("RAICHU")) end) + T.check(#loader.errors > 0, "removed native evolution/encounter target is diagnosed") + T.check(table.concat(loader.errors, "\n"):find("probe", 1, true) ~= nil, "reference failure attributed to removing mod") +end +local _, removal, removedData = run(4, function(mod) + local trainer = mod.content.trainers:get("7") + trainer.parties[1][1].item = nil + ok(mod.content.trainers:patch("7", { parties = trainer.parties })) + ok(mod.content.items:remove("POTION")) + T.eq(mod.content.items:has("POTION"), false, "tombstone immediately hidden in canonical view") +end) +T.eq(#removal.errors, 0, "valid alias removal has no dangling reference") +T.check(removedData.items[17] == nil and removedData.items.ITEM_017 == nil, "numeric/string aliases tombstoned together") + +-- Preferences must still choose original patch operations, not snapshots of +-- earlier mods' changes captured into an adapter's replacement record. +local _, arbitration, arbitrationData = run(2, function(mod, loader) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 75 } })) + loader.content.pokemon:preferOwner("SPECIES_025", "probe") +end, "unified", { + earlier = { format = "native", priority = -1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } }) + end }, +}) +T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.hp, 75, "preferred canonical patch remains") +T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.attack, 55, "set-aside native patch does not leak through a snapshot") + +-- The extractor keeps abbreviated GBA type keys, not the Gen 1 keys. +local types = fixture(3) +types.type_chart = { types = { NORMAL = { category = "physical" }, PSYCHC = { category = "special" } }, matchups = {} } +types.moves.TACKLE.type = "PSYCHC" +local _, typeLoader, typeData = run(3, function(mod) + T.eq(mod.content.moves:get("TACKLE").type, "PSYCHIC_TYPE", "abbreviated GBA type is canonical on read") + ok(mod.content.moves:patch("TACKLE", { type = "PSYCHIC_TYPE" })) + ok(mod.content.pokemon:patch("PIKACHU", { types = { "PSYCHIC_TYPE" } })) +end, "unified", nil, types) +T.eq(#typeLoader.errors, 0, "canonical type resolves to actual native registry") +T.eq(typeData.moves.TACKLE.type, "PSYCHC", "native abbreviated move type retained") +T.eq(typeData.pokemon.PIKACHU.types[1], "PSYCHC", "species type references translated") + +-- A pre-existing unmapped native move selector must not become a dangling +-- registry ID merely because a balance mod changes its power. +local opaque = fixture(4) +opaque.moves[33].effect, opaque.moves[33].gen4Effect = 999, 999 +local _, opaqueLoader, opaqueData = run(4, function(mod) + local record = mod.content.moves:get("TACKLE") + T.check(record.effect == nil and record.native.effect == 999, "opaque selector exposed only in native diagnostics") + ok(mod.content.moves:patch("TACKLE", { power = 65 })) +end, "unified", nil, opaque) +T.eq(#opaqueLoader.errors, 0, "untouched numeric effect selector has no false reference error") +T.eq(opaqueData.moves[33].gen4Effect, 999, "opaque native selector survives patch") + +-- Default/explicit native mode continues to expose live native records. +local default = Manifest.validate({ id = "native", name = "Native", version = "1", entry = "main.lua", api = 2 }, "mods/native") +T.eq(default.content_format, "native", "omitted format remains native") +local _, native = run(2, function(mod) + T.eq(mod.contentFormat, "native", "native manifest uses original facade") + T.check(mod.content.pokemon:get("PIKACHU") == nil, "native facade does not invent aliases") + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 99 } }) +end, "native") +T.eq(native.baseData.pokemon.SPECIES_025.baseStats.attack, 99, "native patch still has original behavior") + +-- Full read/modify/write of one time must not flatten the others to DAY. +local timeData = fixture(2) +timeData.encounters.ROUTE_PROBE.grass.byTime.morn.rate = 5 +timeData.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level = 2 +local _, timeLoader, editedTimes = run(2, function(mod) + local record = mod.content.encounters:get("ROUTE_PROBE") + record.grass.times.NITE.slots[1].level = 11 + ok(mod.content.encounters:override("ROUTE_PROBE", record)) +end, "unified", nil, timeData) +T.eq(timeLoader.mods.probe.state, "loaded", "time-specific detached edit succeeds") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.rate, 20, "time edit preserves day rate") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.rate, 5, "time edit preserves distinct morning rate") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level, 2, "time edit preserves distinct morning slots") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 11, "time edit changes only requested night slot") + +local _, mixedTimeLoader, mixedTimeData = run(2, function(mod) + local record = mod.content.encounters:get("ROUTE_PROBE") + record.grass.rate = 30 + record.grass.times.NITE.slots[1].level = 12 + ok(mod.content.encounters:override("ROUTE_PROBE", record)) +end) +T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.rate, 30, "inherited night rate cannot undo shared edit") +T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 12, "explicit night slot edit survives shared rate") + +local _, conflictingStats = run(2, function(mod) + raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 50, specialAttack = 80 } }) end, "conflicting") +end) +T.eq(ops(conflictingStats.content.pokemon), 0, "conflicting stat aliases leave no ops") + +-- The unsupported DS writer must not disable native reference auditing. +local seasonal = fixture(5) +seasonal.encounters[1].seasons = { winter = { grass = { { species = 26, level = 8, chance = 100 } } } } +local _, seasonalLoader = run(5, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { evolutions = {} })) + ok(mod.content.pokemon:remove("RAICHU")) +end, "unified", nil, seasonal) +T.check(table.concat(seasonalLoader.errors, "\n"):find("seasons.winter.grass", 1, true) ~= nil, + "DS seasonal encounter target is included in removal diagnostics") + +Version.set(original) +T.finish("unified content") From 4822a59042c2b65ca2a8d9883d34b3e1f056004a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:00:39 +0200 Subject: [PATCH 03/71] Add opt-in unified hook and event record boundaries --- docs/modding.md | 8 + docs/unified-mod-callbacks.md | 177 +++++++ src/mods/CallbackRecords.lua | 670 +++++++++++++++++++++++++ src/mods/Loader.lua | 4 + src/mods/Manifest.lua | 5 + src/mods/UnifiedCallbacks.lua | 214 ++++++++ src/mods/UnifiedIds.lua | 85 ++++ tests/engine/gate_meta_coverage.lua | 2 - tests/engine/unified_mod_callbacks.lua | 634 +++++++++++++++++++++++ 9 files changed, 1797 insertions(+), 2 deletions(-) create mode 100644 docs/unified-mod-callbacks.md create mode 100644 src/mods/CallbackRecords.lua create mode 100644 src/mods/UnifiedCallbacks.lua create mode 100644 src/mods/UnifiedIds.lua create mode 100644 tests/engine/unified_mod_callbacks.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..1f52f9979 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -192,6 +192,14 @@ default** (1x front, 2x back). ball-to-pic grow multiplies your scale through each stage, so a rescaled mon still grows into place from the ball, grounded the whole way. +## Unified hook and event records (opt-in) + +API-2 mods can select `"callback_format": "unified"` independently of their +content format. Known Pokémon, battler, trainer-slot and encounter records +then use common fields and symbolic IDs. Existing mods keep native payloads. +See [the callback format and mutation contract](unified-mod-callbacks.md) for +adapted arguments/results, native handles and validation rules. + ## Developer console Boot with developer mode on to unlock the in-game console and hot-reload diff --git a/docs/unified-mod-callbacks.md b/docs/unified-mod-callbacks.md new file mode 100644 index 000000000..a79661ca6 --- /dev/null +++ b/docs/unified-mod-callbacks.md @@ -0,0 +1,177 @@ +# Unified hook and event records + +This is an additive, explicit Mod API 2 option inspired by Gen1Recomp's +unified callback boundary. It adapts Gen2Recomped's existing call sites across +generations 1–5. It does not introduce Gen1Recomp API 3 hooks or events. + +```json +{ + "id": "portable_hooks", + "name": "Portable hooks", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "callback_format": "unified" +} +``` + +Absent/`"native"` keeps the original API, including object identities and +custom hook names. Unknown format values and unified callbacks on API 1 fail +manifest validation. `mod.callbackFormat` exposes the selected format. The +option is independent of `content_format`; either can be adopted first. +There is no global conversion of saves, registry data or battle state. + +```lua +return function(mod) + mod.events:on("pokemon.level_up", function(event) + mod.log:info("%s reached %d", event.mon.species, event.level) + end) + + mod.hooks:wrap("battle.damage", function(next, ctx) + local damage, info = next() + if ctx.user.mon.species == "PIKACHU" then damage = damage + 1 end + return damage, info + end) + + mod.events:on("pokemon.before_give", function(gift) + if gift.species == "EEVEE" then gift.nickname = "Buddy" end + end) +end +``` + +## Records + +IDs are resolved through this boot's registries. Pokémon, move and item IDs +are symbolic strings, including on numeric-ID DS hosts. Imported numeric +records use their names (e.g. `PIKACHU`); named/custom keys retain their IDs. +No National Dex lookup or hard-coded item-number conversion is used. Unknown +or ambiguous replacement IDs fail validation. An unchanged unknown native +ID can round-trip without being guessed or rewritten. Native aliases remain +one identity even after registry merging creates separate row tables. + +| Record | Common fields | +| --- | --- | +| Live Pokémon | `species`, `item`, `nickname`, `name`, `level`, `hp`, `maxHp`, `status`, `isEgg`, `stats`, `moves`, `native` | +| Stats | `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, `specialDefense` | +| Live move slot | `{ id, pp, maxPp, ppUps }` | +| Battler | `{ mon, id, side, native }`; `side` is `"player"`/`"enemy"` when known | +| Trainer party slot | `species`, `level`, optional `item`, `moves` as symbolic ID strings, `native`; other native metadata is read-only | +| Encounter / fishing slot | `species`, `level` or `min`/`max`; native slot metadata retained | +| Move definition | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `priority`, `category`, symbolic `effect` when available, `native` | +| Species definition | `id`, `name`, `baseStats`, `types`, `baseExp`, `catchRate`, `growthRate`, `native` | +| Item definition | `id`, `name`, `price`, `description`, `consumable`, `native` | + +Optional fields remain absent if the native data does not provide them. +`maxHp` maps to native `mon.stats.hp`. Special stats map to `spatk`/`spdef`; +Gen 1's single special stat is exposed through all three special fields. +Editing one keeps its aliases consistent; contradictory edits are rejected. +Type aliases match unified content IDs (`ELECTR` → `ELECTRIC`, `FIGHT` → +`FIGHTING`, `PSYCHIC`/`PSYCHC` → `PSYCHIC_TYPE`, `MYSTERY` → `CURSE_TYPE`). +Status values are `sleep`, `poison`, `burn`, `freeze`, `paralyze`, `toxic`, or +`nil`; clearing uses `nil` or `false`. Unknown existing statuses are retained +until explicitly changed. `isEgg` is the native boolean flag. + +Definitions and `native` snapshots are detached, read-only diagnostics. Use +content registration during mod initialization to change definitions. The +callback view intentionally exposes a limited common definition shape; +additional generation-specific fields are available under `native`. +Battler metadata is read-only. Change its live Pokémon via `battler.mon`. +Shared native records share one public view within a call, so `user.mon` and +`target.mon` remain the same object when they reference the same Pokémon. + +## Adapted hooks + +| Hook | Adapted boundary | +| --- | --- | +| `battle.accuracy`, `battle.damage`, `battle.crit`, `exp.gain` | Context records: battlers, Pokémon, move/species definitions, known ID fields | +| `battle.run`, `battle.catch_exp`, `battle.low_health_alarm` | Context shell; native battle handle | +| `battle.turn_order` | Both battler/move pairs; bound battler result preserves native identity | +| `battle.enemy_action` | Second argument battler; move-action `id` in result | +| `battle.exp_award` | `alive` Pokémon list and `applyShare` helper | +| `catch.rate` | Ball ID, Pokémon, species definition, `opts.ballDef` item definition | +| `trainer.party` | Third argument and result use trainer slots, not live saved Pokémon | +| `evolution.check` | Second argument live Pokémon, evolution/trigger context known IDs | +| `fieldmove.eligibility` | Move ID and bound live Pokémon result | +| `encounter.fishing` | Rod ID, candidate list and encounter result | +| `encounter.roll` | Context and encounter result; imported encounter-table argument stays native | +| `encounter.species` | Encounter argument/result and context | +| `pokemon.sprite`, `pokemon.icon` | Context Pokémon and species ID; path result stays native | +| `ui.party.submenu` | Third argument live Pokémon; menu rows and nested callbacks stay native | + +The remaining native hooks pass their arguments/results through. In +particular, UI callbacks, renderers, input, script arguments, maps, movement, +saves, intro speech and link fingerprint data retain their engine contracts. +Registering an unknown engine callback in unified mode fails immediately. +The source-catalog test forces an explicit boundary decision for new seams; +registration is not a promise that a given game executes that seam. Where +available, use `mod:supports("hooks"/"events", name)` to query the host. + +`battle.exp_award`'s `ctx.applyShare(mon, divisor, announce)` accepts a bound +public Pokémon, commits pending validated edits, calls the native helper, +and refreshes the public views. It retains native argument/result behavior, +including errors. Retained helpers and `next` cannot be used after the hook +callback returns. Live party membership/order cannot be changed through this +view. New encounter, enemy-action and trainer-party records may be returned; +live Pokémon/battler replacements must be records bound during that call. + +## Mutation and failure behavior + +Edits to represented live Pokémon fields, trainer slots, encounters and +context fields are validated before being written. Levels must be 1–100; +stats/max HP positive integers; HP must fit the maximum. Move lists contain +at most four entries, PP must fit the selected move and PP Ups, and `maxPp` +is read-only. Trainer parties contain at most six slots. Unsupported fields, +read-only changes and invalid IDs reject the transaction before any staged +record is changed. Hidden native metadata (IVs, EVs, personality, slot fields) +is retained. Native Pokémon, stats tables, move lists and existing move slots +keep their identities, including battle aliases into those tables. + +Before `next()`, validated edits are committed together. After downstream +returns, public views refresh from native state. A successful callback's +later edits commit together too. Throwing before `next()` skips the wrapper; +throwing/validation failure after `next()` keeps downstream results and +changes. Already committed edits and native helper effects are not undone. +Calling `next()` again returns the cached result (including nil holes), or +rethrows the cached error; it never repeats downstream gameplay. Native +errors preserve their identity and are not converted into mod failures. + +Only the represented record edits participate in this transaction. Game, +battle, save, world, map, data and RNG handles stay native, as do unclassified +engine-specific fields; direct changes through such handles have ordinary +native side effects. Changing a species/level does not automatically rebuild +battle definitions, recalculate stats, grant moves or emit evolution events. +Use the engine's gameplay operations when that bookkeeping is required. + +## Events + +Ordinary engine notifications copy their outer context and adapt known +record fields/IDs: battle Pokémon/battlers/moves, level-up learnable move IDs, +move actions, caught/received/evolved Pokémon and trade records. Edits to +these snapshots do not write back. Engine handles remain native, including +`side`, `battle`, `game`, interaction targets and script contexts. A status +source such as `SYNCHRONIZE` remains an engine label rather than a guessed +move ID. + +`pokemon.before_give` is the explicit mutable exception: `species`, `level` +and optional `nickname` are validated and committed atomically. Its script +`ctx` stays native. A failure changes none of those gift fields, and later +listeners continue through the original event bus. + +Save lifecycle/repair events, screen and intro-speech events, `game.ready`, +`mods.loaded`, and mod option events pass through natively. This preserves +save repair semantics, forwarding metatables and nested callback identities. +Namespaced `mod..` events also pass through unchanged. +Mods may listen to other mods' events and emit only their own namespace. +Priorities, unsubscribe, once semantics, owner attribution and failed-entry +rollback are provided by the original event/hook buses. + +## Verification + +`tests/engine/unified_mod_callbacks.lua` runs through a ROM-free in-memory +filesystem and the real loader/event/hook buses across every supported host. +It covers string/numeric IDs and aliases, transactional writeback, native +identity, mixed mod chains, `next`/helper failures, event isolation, mutable +gifts, priorities, ownership rollback and the native default. The catalog +checks compare against actual source call sites, rather than another list. +Run it with the same LuaJIT runtime as the engine, then run `scripts/test.sh`. +ROM-dependent gameplay checks still require an imported legal ROM. diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua new file mode 100644 index 000000000..6d702f4b1 --- /dev/null +++ b/src/mods/CallbackRecords.lua @@ -0,0 +1,670 @@ +-- A call-local transaction. Only known record roles cross this boundary; +-- opaque engine handles (Game, battle, save, map, RNG, UI callbacks) stay native. +local Ids = require("src.mods.UnifiedIds") +local R = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function shallow(t) + local out = {} + for k, v in pairs(t or {}) do + out[k] = v + end + return out +end +local function copy(v, seen) + if type(v) ~= "table" then + if type(v) == "function" then + return nil + end + return v + end + seen = seen or {} + if seen[v] then + return seen[v] + end + local out = {} + seen[v] = out + for k, x in pairs(v) do + out[k] = copy(x, seen) + end + return out +end +local function equal(a, b, seen) + if a == b then + return true + end + if type(a) ~= "table" or type(b) ~= "table" then + return false + end + seen = seen or {} + if seen[a] == b then + return true + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end +local function integer(v, lo, hi, label) + assert(type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi, label .. " out of range") + return v +end +local status = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local short = {} +for k, v in pairs(status) do + short[v] = k +end +local stats = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} +local typeAliases = { + PSYCHIC = "PSYCHIC_TYPE", + PSYCHC = "PSYCHIC_TYPE", + MYSTERY = "CURSE_TYPE", + FIGHT = "FIGHTING", + ELECTR = "ELECTRIC", +} +local function typeRead(id) + return typeAliases[id] or id +end +local monFields = { + species = true, + item = true, + nickname = true, + name = true, + level = true, + hp = true, + maxHp = true, + status = true, + isEgg = true, + stats = true, + moves = true, + native = true, +} +local idRoles = { + species = "pokemon", + fromSpecies = "pokemon", + toSpecies = "pokemon", + evolveTo = "pokemon", + moveId = "moves", + item = "items", + itemId = "items", + ball = "items", +} +local recordRoles = { + mon = "mon", + pokemon = "mon", + user = "battler", + target = "battler", + attacker = "battler", + defender = "battler", + battler = "battler", + incoming = "battler", + previous = "battler", + move = "move", + defeatedDef = "species", + ballDef = "item", + opts = "context", + alive = "party", + sent = "mon", + received = "mon", + playerAction = "action", + enemyAction = "action", +} +local function array(t, label, max) + assert(type(t) == "table", label .. " must be a list") + local n, count = #t, 0 + assert(not max or n <= max, label .. " too long") + for k in pairs(t) do + count = count + 1 + assert(type(k) == "number" and k % 1 == 0 and k >= 1 and k <= n, label .. " must be dense") + end + assert(count == n, label .. " must be dense") +end + +function R.new(loader, name, generation) + local ids = Ids.new(loader) + local byRaw, byView, entries = {}, {}, {} + local view, restore, commit, refresh + local open = true + local function read(kind, id) + return ids:read(kind, id) + end + local function native(kind, id, old) + if id == read(kind, old) then + return old + end + if id == nil or id == false then + return nil + end + assert(type(id) == "string", kind .. " ID must be a string") + local entry, why = ids:resolve(kind, id) + assert(entry, why) + -- Keep each host's existing numeric/string representation. + if type(old) == "string" then + for _, key in ipairs(entry.keys) do + if type(key) == "string" then + return key + end + end + end + return entry.key + end + local function moveDef(id) + local group = ids:resolve("moves", read("moves", id)) + return group and loader.content.moves:get(group.key) or nil + end + local function moveViews(raw) + local out = {} + for i, m in ipairs(raw or {}) do + local id = type(m) == "table" and m.id or m + local def = moveDef(id) + local ups = type(m) == "table" and (m.ppUps or 0) or 0 + out[i] = { + id = read("moves", id), + pp = type(m) == "table" and m.pp or nil, + maxPp = def and math.floor((def.pp or 0) + ups * math.floor((def.pp or 0) / 5)) or nil, + ppUps = ups, + } + end + return out + end + local function statView(raw) + local out = {} + for k, n in pairs(stats) do + out[k] = (raw or {})[n] + end + out.specialAttack = out.specialAttack or out.special + out.specialDefense = out.specialDefense or out.special + return out + end + local function fields(raw, kind) + if kind == "mon" then + return { + species = read("pokemon", raw.species), + item = read("items", raw.item), + nickname = raw.nickname, + name = raw.name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = status[raw.status] or raw.status, + isEgg = raw.isEgg == true, + stats = statView(raw.stats), + moves = moveViews(raw.moves), + native = copy(raw), + } + elseif kind == "battler" then + return { + mon = view(raw.mon, "mon"), + id = raw.id, + side = raw.isPlayer == true and "player" or raw.isPlayer == false and "enemy" or nil, + native = copy({ name = raw.name, species = raw.species, stages = raw.stages }), + } + elseif kind == "move" or kind == "species" or kind == "item" then + local out = { native = copy(raw), name = raw.name } + local registryKind = kind == "species" and "pokemon" or kind == "move" and "moves" or "items" + local key = raw.id or raw.index or raw.numId + if key == nil then + for id, row in loader.content[registryKind]:each() do + if row == raw then + key = id + break + end + end + end + out.id = read(registryKind, key) + if kind == "species" then + out.baseStats = statView(raw.baseStats) + out.types = {} + for i, t in ipairs(raw.types or {}) do + out.types[i] = typeRead(t) + end + out.baseExp = raw.baseExp or raw.expYield + out.catchRate = raw.catchRate + out.growthRate = raw.growthRate + elseif kind == "move" then + for _, key in ipairs({ "type", "power", "accuracy", "pp", "priority" }) do + out[key] = raw[key] + end + out.type = typeRead(out.type) + out.category = raw.category or raw.class + out.effect = type(raw.effect) == "string" and raw.effect or nil + else + for _, key in ipairs({ "price", "description", "consumable" }) do + out[key] = raw[key] + end + end + return out + elseif kind == "party" or kind == "candidates" or kind == "trainerParty" then + local out = {} + local role = kind == "party" and "mon" or kind == "trainerParty" and "trainerMon" or "encounter" + for i, v in ipairs(raw) do + out[i] = view(v, role) + end + return out + elseif kind == "trainerMon" then + local out = copy(raw) + out.species = read("pokemon", raw.species) + out.item = read("items", raw.item) + out.moves = {} + for i, id in ipairs(raw.moves or {}) do + out.moves[i] = read("moves", id) + end + out.native = copy(raw) + return out + end + local out = shallow(raw) + for k, v in pairs(raw) do + local role = recordRoles[k] + if name == "world.interacted" and k == "target" then + role = nil + end + if name == "trade.completed" and k == "evolution" then + role = "context" + end + if name == "pokemon.before_give" and k == "ctx" then + role = nil + end + if k == "status" then + out[k] = status[v] or v + elseif k == "move" and type(v) ~= "table" then + out[k] = read("moves", v) + elseif kind == "action" and k == "id" and not raw.special then + out[k] = read("moves", v) + elseif idRoles[k] and type(v) ~= "table" then + out[k] = read(idRoles[k], v) + elseif role and type(v) == "table" then + out[k] = view(v, role) + elseif k == "learnable" and name == "pokemon.level_up" then + out[k] = {} + for i, id in ipairs(v) do + out[k][i] = read("moves", id) + end + elseif k == "applyShare" and name == "battle.exp_award" and type(v) == "function" then + out[k] = function(mon, ...) + local bound = byView[mon] + assert(bound and bound.kind == "mon", "applyShare requires a bound Pokemon") + commit() + local result = pack(pcall(v, bound.raw, ...)) + if not result[1] then + error(result[2], 0) + end + refresh() + return unpack(result, 2, result.n) + end + end + end + return out + end + view = function(raw, kind) + if type(raw) ~= "table" then + return raw + end + byRaw[raw] = byRaw[raw] or {} + if byRaw[raw][kind] then + return byRaw[raw][kind].public + end + local e = { raw = raw, kind = kind, public = {} } + byRaw[raw][kind] = e + byView[e.public] = e + entries[#entries + 1] = e + for k, v in pairs(fields(raw, kind)) do + e.public[k] = v + end + e.before = ( + kind == "action" + or kind == "gift" + or kind == "context" + or kind == "encounter" + or kind == "party" + or kind == "trainerParty" + or kind == "candidates" + ) + and shallow(e.public) + or copy(e.public) + return e.public + end + local function statChanges(public, old, baseline, ops) + assert(type(old) == "table", "native stats are missing") + assert(type(public) == "table", "stats must be a record") + for k in pairs(public) do + assert(stats[k], "unknown stat " .. tostring(k)) + end + local specialValue + for _, k in ipairs({ "special", "specialAttack", "specialDefense" }) do + if public[k] ~= baseline[k] and (generation == 1 or k ~= "specialDefense") then + assert(not specialValue or specialValue == public[k], "conflicting special stats") + specialValue = public[k] + end + end + for k, n in pairs(stats) do + local value = public[k] + if generation == 1 and (k == "special" or k == "specialAttack" or k == "specialDefense") then + value = specialValue or baseline[k] + n = "special" + elseif specialValue and (k == "special" or k == "specialAttack") then + value = specialValue + end + if value ~= baseline[k] then + value = integer(value, 1, 65535, "stats." .. k) + ops[#ops + 1] = function() + old[n] = value + end + end + end + end + local pending, visiting + local function stage(raw, key, value) + assert(type(raw) == "table", "native record is missing") + pending[#pending + 1] = function() + raw[key] = value + end + end + local function movesNative(public, old, baseline) + array(public, "moves", 4) + local out = {} + for i, m in ipairs(public) do + assert(type(m) == "table", "move must be a record") + for k in pairs(m) do + assert(k == "id" or k == "pp" or k == "maxPp" or k == "ppUps", "unknown move field") + end + local prev = (old or {})[i] + local prior = (baseline or {})[i] or {} + assert(m.maxPp == prior.maxPp, "maxPp is read-only") + local row = type(prev) == "table" and shallow(prev) or {} + row.id = native("moves", m.id, row.id) + assert(row.id, "move ID is required") + row.ppUps = integer(m.ppUps or 0, 0, 3, "ppUps") + local def = moveDef(row.id) + local max = def and math.floor((def.pp or 0) + row.ppUps * math.floor((def.pp or 0) / 5)) or 0 + row.pp = integer(m.pp, 0, max, "pp") + out[i] = row + end + return out + end + restore = function(value, kind) + if type(value) ~= "table" then + assert( + value == nil or (kind == "encounter" and value == false), + "expected " .. tostring(kind) .. " record" + ) + return value + end + local e = byView[value] + if not e then + assert( + kind == "action" + or kind == "encounter" + or kind == "trainerMon" + or kind == "candidates" + or kind == "trainerParty", + "replacement " .. tostring(kind) .. " must be a bound record" + ) + e = { public = value, kind = kind, raw = {}, before = {} } + end + if visiting[e] then + return e.raw + end + visiting[e] = true + local raw, before = e.raw, e.before + kind = e.kind + if kind == "move" or kind == "species" or kind == "item" or kind == "battler" then + if kind == "battler" then + assert( + value.id == before.id and value.side == before.side and equal(value.native, before.native), + "battler metadata is read-only" + ) + assert(byView[value.mon] and byView[value.mon].raw == raw.mon, "battler.mon identity is read-only") + for k in pairs(value) do + assert(k == "mon" or k == "id" or k == "side" or k == "native", "unknown battler field") + end + restore(value.mon, "mon") + else + assert(equal(value, before), kind .. " definition is read-only; use mod.content") + end + elseif kind == "mon" then + for k in pairs(value) do + assert(monFields[k], "unknown Pokemon field " .. tostring(k)) + end + assert(equal(value.native, before.native), "native snapshot is read-only") + assert(value.name == before.name, "name is read-only; set nickname") + for _, key in ipairs({ "species", "item", "nickname", "level", "hp", "maxHp", "status", "isEgg" }) do + local v = value[key] + if not equal(v, before[key]) then + if key == "species" or key == "item" then + v = native(key == "species" and "pokemon" or "items", v, raw[key]) + assert(key ~= "species" or v, "species is required") + elseif key == "level" then + v = integer(v, 1, 100, key) + elseif key == "hp" then + v = integer(v, 0, 65535, key) + elseif key == "maxHp" then + v = integer(v, 1, 65535, key) + elseif key == "status" then + assert(v == nil or v == false or short[v], "unknown status") + v = short[v] + elseif key == "nickname" then + assert(v == nil or type(v) == "string", "nickname must be a string") + elseif key == "isEgg" then + assert(type(v) == "boolean", "isEgg must be boolean") + end + if key == "maxHp" then + stage(raw.stats, "hp", v) + else + stage(raw, key, v) + end + end + end + if value.maxHp ~= before.maxHp and value.stats.hp ~= before.stats.hp then + assert(value.maxHp == value.stats.hp, "conflicting maxHp and stats.hp") + end + local limit = value.stats.hp ~= before.stats.hp and value.stats.hp or value.maxHp + if value.hp ~= before.hp or limit ~= before.maxHp then + assert( + type(value.hp) == "number" and type(value.maxHp) == "number" and value.hp <= limit, + "hp exceeds maxHp" + ) + end + if not equal(value.stats, before.stats) then + statChanges(value.stats, raw.stats, before.stats, pending) + end + if not equal(value.moves, before.moves) then + local new = movesNative(value.moves, raw.moves, before.moves) + local target = raw.moves or {} + stage(raw, "moves", target) + pending[#pending + 1] = function() + local previous = shallow(target) + for i in pairs(target) do + target[i] = nil + end + for i, row in ipairs(new) do + local slot = type(previous[i]) == "table" and previous[i] or {} + for k in pairs(slot) do + slot[k] = nil + end + for k, v in pairs(row) do + slot[k] = v + end + target[i] = slot + end + end + end + elseif kind == "party" or kind == "candidates" or kind == "trainerParty" then + array(value, kind, kind ~= "candidates" and 6 or nil) + local out = {} + local role = kind == "party" and "mon" or kind == "trainerParty" and "trainerMon" or "encounter" + for i, v in ipairs(value) do + out[i] = restore(v, role) + end + if kind == "party" then + assert(#value == #raw, "live party membership is read-only") + for i, v in ipairs(out) do + assert(v == raw[i], "live party order is read-only") + end + else + pending[#pending + 1] = function() + for k in pairs(raw) do + raw[k] = nil + end + for i, v in ipairs(out) do + raw[i] = v + end + end + end + elseif kind == "gift" then + assert(value.ctx == before.ctx, "gift context is read-only") + for k in pairs(value) do + assert(k == "ctx" or k == "species" or k == "level" or k == "nickname", "unknown gift field") + end + stage(raw, "species", assert(native("pokemon", value.species, raw.species), "species is required")) + stage(raw, "level", integer(value.level, 1, 100, "level")) + assert(value.nickname == nil or type(value.nickname) == "string", "nickname must be a string") + stage(raw, "nickname", value.nickname) + elseif kind == "trainerMon" then + assert(value.native == nil or equal(value.native, before.native), "native snapshot is read-only") + local allowed = { species = true, item = true, level = true, moves = true, native = true } + for k, v in pairs(value) do + if not allowed[k] then + assert(equal(v, before[k]), "trainer metadata is read-only") + end + end + stage(raw, "species", assert(native("pokemon", value.species, raw.species), "species is required")) + stage(raw, "item", native("items", value.item, raw.item)) + stage(raw, "level", integer(value.level, 1, 100, "level")) + if not equal(value.moves, before.moves) then + array(value.moves, "trainer moves", 4) + local out = {} + for i, id in ipairs(value.moves) do + out[i] = assert(native("moves", id, (raw.moves or {})[i]), "move is required") + end + stage(raw, "moves", out) + end + else + if kind == "encounter" then + assert(value.species ~= nil, "encounter species is required") + if value.level ~= nil then + integer(value.level, 1, 100, "encounter level") + end + for _, k in ipairs({ "min", "max" }) do + if value[k] ~= nil then + integer(value[k], 1, 100, k) + end + end + assert( + value.level ~= nil or (value.min and value.max and value.min <= value.max), + "encounter level is required" + ) + end + local keys = shallow(before) + for k in pairs(value) do + keys[k] = true + end + for k in pairs(keys) do + local v = value[k] + local old = raw[k] + if type(v) == "table" and byView[v] then + local restored = restore(v) + if v ~= before[k] then + stage(raw, k, restored) + end + elseif k == "move" and type(v) ~= "table" and v ~= before[k] then + stage(raw, k, native("moves", v, old)) + elseif kind == "action" and k == "id" and not value.special and v ~= before[k] then + stage(raw, k, native("moves", v, old)) + elseif k == "status" and v ~= before[k] then + assert(v == nil or v == false or short[v], "unknown status") + stage(raw, k, short[v]) + elseif idRoles[k] and v ~= before[k] then + stage(raw, k, native(idRoles[k], v, old)) + elseif k == "applyShare" and name == "battle.exp_award" then + assert(v == before[k], "applyShare is read-only") + elseif k == "learnable" and name == "pokemon.level_up" then + assert(equal(v, before[k]), "learnable is read-only") + elseif v ~= before[k] then + local role = recordRoles[k] + if kind == "encounter" and k == "level" then + integer(v, 1, 100, "encounter level") + end + if role and type(v) == "table" then + v = restore(v, role) + end + stage(raw, k, v) + end + end + end + return raw + end + commit = function(extra) + assert(open, "callback transaction is closed") + pending, visiting = {}, {} + for _, e in ipairs(entries) do + restore(e.public, e.kind) + end + local result + if extra then + result = extra() + end + for _, op in ipairs(pending) do + op() + end + return result + end + refresh = function() + -- Reuse public identities, but refresh after vanilla mutates native data. + for _, e in ipairs(entries) do + local nextValue = fields(e.raw, e.kind) + for k in pairs(e.public) do + e.public[k] = nil + end + for k, v in pairs(nextValue) do + e.public[k] = v + end + end + for _, e in ipairs(entries) do + e.before = ( + e.kind == "action" + or e.kind == "gift" + or e.kind == "context" + or e.kind == "encounter" + or e.kind == "party" + or e.kind == "trainerParty" + or e.kind == "candidates" + ) + and shallow(e.public) + or copy(e.public) + end + end + return { + close = function() + open = false + end, + view = view, + restore = function(v, kind) + return restore(v, kind) + end, + commit = commit, + refresh = refresh, + read = read, + native = native, + copy = copy, + } +end +return R diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..02b33db62 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -891,6 +891,7 @@ function Loader:_api(mod) }, -- a deep copy: what a mod does to its own view never reaches the loader manifest = Merge.deepCopy(mod.manifest), + callbackFormat = mod.manifest.callback_format or "native", content = {}, exports = {}, DELETE = Registry.DELETE, @@ -999,6 +1000,9 @@ function Loader:_api(mod) }, } self.exports[modId] = api.exports + if api.callbackFormat == "unified" then + engineRequire("src.mods.UnifiedCallbacks").install(loader, api, modId, hostGen) + end -- a handle, not the mod object: {id, version, exports} or nil when the -- other mod is absent, disabled, failed, or has not run yet. Tolerates -- mod:find(id) as well as the documented mod.find(id). diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..09f2a1abb 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -279,6 +279,10 @@ function Manifest.validate(raw, path) return value end + assert(raw.callback_format == nil or raw.callback_format == "native" or raw.callback_format == "unified", + "callback_format must be native or unified") + assert(raw.callback_format ~= "unified" or api >= 2, "callback_format unified requires mod API 2") + local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible) -- `support`: where to thank the mod's creator -- a Ko-fi, a Patreon, a @@ -316,6 +320,7 @@ function Manifest.validate(raw, path) category = raw.category or "OTHER", game_version = raw.game_version, description = raw.description or "", + callback_format = raw.callback_format or "native", github = github, support = support, updateCheck = updateCheck, diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua new file mode 100644 index 000000000..ce1659a02 --- /dev/null +++ b/src/mods/UnifiedCallbacks.lua @@ -0,0 +1,214 @@ +local Records = require("src.mods.CallbackRecords") +local Ids = require("src.mods.UnifiedIds") +local C = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function names(text) + local out = {} + for name in text:gmatch("%S+") do + out[name] = true + end + return out +end +-- This is an audited boundary catalog, not a claim that every game executes +-- every seam. The source-catalog test requires a decision for each new seam. +C.hooks = names([[ + battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award + battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate + encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility + input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed + music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud + render.letterbox render.zones save.new_game save.write script.command trainer.party transition.style + ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items + ui.title_menu.items warp.destination world.tod zoom.range +]]) +C.events = names([[ + assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt battle.ended + battle.exp_gained battle.fainted battle.move_used battle.started battle.status_inflicted battle.turn_ended + battle.turn_started discord.join_requested flag.changed game.ready intro.birch_speech.finished + intro.birch_speech.started intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started + intro.oak_speech.step link.connected link.desync link.ended map.entered map.exited map.reloaded + mod.option_action mod.options_changed mods.loaded music.started music.stopped player.warped + pokemon.before_give pokemon.caught pokemon.egg_hatched pokemon.evolved pokemon.level_up + pokemon.move_learned pokemon.received save.created save.loaded save.loading save.writing screen.popped + screen.pushed script.ended script.started sound.played trade.completed world.blacked_out + world.block_replaced world.blocks_replaced world.boulder_moved world.interacted world.npc_spawned + world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged +]]) +local positions = { + ["battle.accuracy"] = { "context" }, + ["battle.damage"] = { "context" }, + ["battle.crit"] = { "context" }, + ["battle.run"] = { "context" }, + ["battle.catch_exp"] = { "context" }, + ["battle.low_health_alarm"] = { "context" }, + ["battle.exp_award"] = { "context" }, + ["exp.gain"] = { "context" }, + ["battle.turn_order"] = { "battler", "move", "battler", "move", "context" }, + ["battle.enemy_action"] = { false, "battler" }, + ["catch.rate"] = { "items", "mon", "species", "context" }, + ["trainer.party"] = { false, false, "trainerParty" }, + ["evolution.check"] = { false, "mon", "context", "context" }, + ["fieldmove.eligibility"] = { "moves", false }, + ["encounter.fishing"] = { "items", false, "candidates" }, + ["encounter.roll"] = { false, "context" }, + ["encounter.species"] = { "encounter", "context" }, + ["ui.party.submenu"] = { false, false, "mon", false }, + ["pokemon.sprite"] = { false, "context" }, + ["pokemon.icon"] = { false, "context" }, +} +local results = { + ["fieldmove.eligibility"] = "mon", + ["battle.enemy_action"] = "action", + ["trainer.party"] = "trainerParty", + ["battle.turn_order"] = "battler", + ["encounter.fishing"] = "encounter", + ["encounter.roll"] = "encounter", + ["encounter.species"] = "encounter", +} +-- These payloads are engine handles, repair flows or UI objects, not record +-- notifications. In particular, game.ready has a forwarding metatable. +local nativeEvents = names([[ + game.ready save.created save.loaded save.loading save.writing mods.loaded + screen.popped screen.pushed intro.birch_speech.finished intro.birch_speech.started + intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step + mod.option_action mod.options_changed +]]) +local function custom(name) + return type(name) == "string" and name:match("^mod%.[%w_%-]+%..+$") ~= nil +end +function C.install(loader, api, owner, generation) + -- Capture pristine alias identities before Loader merges them into separate + -- records. Each call still builds a fresh index, including later mods' IDs. + local ids = Ids.new(loader) + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + ids:entries(kind) + end + local function check(catalog, name) + assert( + type(name) == "string" and (catalog[name] or (catalog == C.events and custom(name))), + "unknown unified callback: " .. tostring(name) + ) + end + local function subscribe(once, name, callback, priority) + check(C.events, name) + assert(type(callback) == "function", "event callback must be a function") + local wrapped = callback + if not custom(name) and not nativeEvents[name] then + wrapped = function(payload) + local boundary = Records.new(loader, name, generation) + local public = boundary.view(payload, name == "pokemon.before_give" and "gift" or "context") + local values = pack(callback(public)) + if name == "pokemon.before_give" then + boundary.commit() + end + return unpack(values, 1, values.n) + end + end + if once then + return loader.events:once(name, wrapped, priority, owner) + end + return loader.events:on(name, wrapped, priority, owner) + end + api.events = { + on = function(_, ...) + return subscribe(false, ...) + end, + once = function(_, ...) + return subscribe(true, ...) + end, + emit = function(_, name, payload) + local prefix = "mod." .. owner .. "." + assert(custom(name) and name:sub(1, #prefix) == prefix, "mods may only emit " .. prefix .. "* events") + return loader.events:emit(name, payload) + end, + } + api.hooks = { + wrap = function(_, name, callback, priority) + check(C.hooks, name) + assert(type(callback) == "function", "hook callback must be a function") + return loader.hooks:wrap(name, function(nextFn, ...) + local boundary = Records.new(loader, name, generation) + local roles = positions[name] or {} + local function translate(args) + local out = { n = args.n } + for i = 1, args.n do + local role = roles[i] + local v = args[i] + if role == "items" or role == "moves" then + v = boundary.read(role, v) + elseif role then + v = boundary.view(v, role) + end + out[i] = v + end + return out + end + local original = pack(...) + local function rawArgs(args) + local out = { n = args.n } + for i = 1, args.n do + local role, v = roles[i], args[i] + if role == "items" or role == "moves" then + v = boundary.native(role, v, original[i]) + elseif role then + v = boundary.restore(v, role) + end + out[i] = v + end + return out + end + local public = translate(original) + local called, downstream, publicResult = false, nil, nil + local active = true + local function nextPublic(...) + assert(active, "next is only valid during its hook callback") + assert(not called or downstream, "next is already running") + if not called then + local args = select("#", ...) == 0 and public or pack(...) + local raw = boundary.commit(function() + return rawArgs(args) + end) + called = true + downstream = pack(pcall(nextFn, unpack(raw, 1, raw.n))) + if downstream[1] then + boundary.refresh() + publicResult = pack(unpack(downstream, 2, downstream.n)) + if results[name] then + publicResult[1] = boundary.view(publicResult[1], results[name]) + end + end + end + if not downstream[1] then + error(downstream[2], 0) + end + return unpack(publicResult, 1, publicResult.n) + end + local invoked = pack(pcall(callback, nextPublic, unpack(public, 1, public.n))) + active = false + if not invoked[1] then + boundary.close() + error(invoked[2], 0) + end + local values = pack(unpack(invoked, 2, invoked.n)) + if called and not downstream[1] then + boundary.close() + error(downstream[2], 0) + end + local ok, err = pcall(boundary.commit, function() + if results[name] then + values[1] = boundary.restore(values[1], results[name]) + end + end) + boundary.close() + if not ok then + error(err, 0) + end + return unpack(values, 1, values.n) + end, priority, owner) + end, + } +end +return C diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/tests/engine/gate_meta_coverage.lua b/tests/engine/gate_meta_coverage.lua index 2c29d47cc..d3bbee83b 100644 --- a/tests/engine/gate_meta_coverage.lua +++ b/tests/engine/gate_meta_coverage.lua @@ -111,13 +111,11 @@ local DEBT = { ["hook:encounter.fishing"] = "M5", ["hook:render.zones"] = "M9", - ["hook:trainer.party"] = "M7", ["hook:ui.pc.items"] = "M8", ["event:link.connected"] = "M12", ["event:link.ended"] = "M12", ["event:player.warped"] = "M5", - ["event:pokemon.before_give"] = "M7", ["event:pokemon.evolved"] = "M7", ["event:pokemon.level_up"] = "M7", ["event:pokemon.move_learned"] = "M7", diff --git a/tests/engine/unified_mod_callbacks.lua b/tests/engine/unified_mod_callbacks.lua new file mode 100644 index 000000000..a4059d663 --- /dev/null +++ b/tests/engine/unified_mod_callbacks.lua @@ -0,0 +1,634 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local Manifest = require("src.mods.Manifest") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Catalog = require("tests.modkit.catalog") +local C = require("src.mods.UnifiedCallbacks") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +local function manifest(format, api) + return { + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + api = api or 2, + callback_format = format, + } +end +T.eq(Manifest.validate(manifest()).callback_format, "native", "existing manifests keep native callbacks") +T.eq(Manifest.validate(manifest("unified")).callback_format, "unified", "opt-in accepted") +T.raises(function() + Manifest.validate(manifest("unified", 1)) +end, "requires mod API 2", "unified requires API 2") +T.raises(function() + Manifest.validate(manifest("other")) +end, "callback_format", "unknown format rejected") +for _, invalid in ipairs({ false, 42, {} }) do + T.raises(function() + Manifest.validate(manifest(invalid)) + end, "callback_format", "malformed format rejected") +end +local function set(names) + local out = {} + for _, n in ipairs(names) do + out[n] = true + end + return out +end +T.same(C.hooks, set(Catalog.hooks()), "every native hook has an explicit boundary decision") +T.same(C.events, set(Catalog.events()), "every native event has an explicit boundary decision") +local function boot(version, format, entry) + Version.set(version) + local numeric = Version.generation() >= 4 + local a, b, m, n, item = + numeric and 501 or "ALPHA", + numeric and 502 or "BETA", + numeric and 601 or "TACKLE", + numeric and 602 or "EMBER", + numeric and 701 or "POTION" + local data = { + pokemon = { + [a] = { + id = a, + name = "Alpha", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 30, special = 35 }, + types = { + "NORMAL", + }, + }, + [b] = { id = b, name = "Beta", baseStats = { hp = 50 }, types = { "NORMAL" } }, + }, + moves = { + [m] = { id = m, name = "Tackle", pp = 35, power = 40 }, + [n] = { + id = n, + name = "Ember", + pp = 25, + power = 40, + }, + }, + items = { [item] = { id = item, name = "Potion" } }, + trainers = {}, + encounters = {}, + } + if numeric then + data.pokemon.SPECIES_501 = data.pokemon[a] + data.moves.MOVE_601 = data.moves[m] + end + local json = require("src.link.Json") + local files = { + ["mods/probe/manifest.json"] = json.encode(manifest(format)), + ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() + error("callback opt-in must not construct Game") + end + local loaded = loader:load(data) + if not entry then + assert(loaded) + assert(loader.apis.probe, tostring(loader.errors.probe)) + end + return loader.apis.probe, loader, data, { a, b, m, n, item } +end +local function mon(ids) + return { + species = ids[1], + item = ids[5], + level = 10, + hp = 25, + status = "PSN", + stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24, spatk = 24, spdef = 25 }, + moves = { { id = ids[3], pp = 12 } }, + ivs = { hp = 17 }, + personality = 123, + custom = { keep = true }, + } +end +for _, version in ipairs(Version.ORDER) do + local api, loader, data, ids = boot(version, "unified") + local label = version .. ": " + T.eq(api.callbackFormat, "unified", label .. "format exposed before Game construction") + local raw = mon(ids) + if Version.generation() == 1 then + raw.stats.spatk = nil + raw.stats.spdef = nil + end + local unchanged = require("src.mods.Merge").deepCopy(raw) + local noop = api.hooks:wrap("battle.damage", function(next) + return next() + end) + loader.hooks:call("battle.damage", function() + return 5 + end, { user = { mon = raw } }) + T.same(raw, unchanged, label .. "no-op unified callback leaves native record exactly unchanged") + T.eq(raw.isEgg, nil, label .. "false view flag does not materialize native field") + noop() + local originalStats, originalMoves, originalSlot = raw.stats, raw.moves, raw.moves[1] + local battler = + { mon = raw, isPlayer = true, curStats = raw.stats, curMoves = raw.moves, stages = { attack = 0 } } + local battle = { tag = "native handle" } + local count = 0 + local off = api.hooks:wrap("battle.damage", function(next, ctx) + T.eq(ctx.battle, battle, label .. "battle stays native") + T.eq(ctx.user.mon, ctx.target.mon, label .. "shared mon preserves public alias") + T.eq(ctx.user.mon.species, "ALPHA", label .. "canonical species") + T.eq(ctx.user.mon.item, "POTION", label .. "canonical held item") + T.eq(ctx.user.mon.moves[1].id, "TACKLE", label .. "canonical moves") + T.eq(ctx.user.mon.status, "poison", label .. "canonical status") + T.eq(ctx.user.mon.maxHp, 40, label .. "maxHp reads stats.hp") + T.eq(ctx.user.mon.stats.specialAttack, 24, label .. "canonical special attack") + ctx.user.mon.hp = 24 + ctx.user.mon.status = "burn" + ctx.user.mon.nickname = "Test" + ctx.user.mon.stats.attack = 26 + ctx.user.mon.moves[1].id = "EMBER" + ctx.user.mon.moves[1].pp = 20 + local n, hole, last = next() + T.eq(ctx.user.mon.hp, 19, label .. "native downstream changes refreshed") + T.eq(select("#", next()), 3, label .. "cached next retains nil holes") + ctx.user.mon.hp = 18 + return n + 1, hole, last + end) + local n, hole, last = loader.hooks:call("battle.damage", function(ctx) + count = count + 1 + T.eq(ctx.user, battler, label .. "native battler identity restored") + T.eq(ctx.user.mon, raw, label .. "native mon identity restored") + T.eq(raw.hp, 24, label .. "pre-next mutation committed") + T.eq(raw.status, "BRN", label .. "status written in native vocabulary") + T.eq(raw.moves[1].id, ids[4], label .. "move ID restored for host") + raw.hp = 19 + return 5, nil, "tail" + end, { battle = battle, user = battler, target = battler, move = data.moves[ids[3]] }) + T.eq(n, 6, label .. "scalar result postprocessing") + T.eq(last, "tail", label .. "result tail preserved") + T.eq(hole, nil, label .. "nil preserved") + T.eq(count, 1, label .. "vanilla runs once") + T.eq(raw.hp, 18, label .. "post-next edit committed") + T.eq(raw.stats, originalStats, label .. "stats table identity preserved") + T.eq(raw.moves, originalMoves, label .. "moves list identity preserved") + T.eq(raw.moves[1], originalSlot, label .. "move slot identity preserved") + T.eq(raw.personality, 123, label .. "hidden personality retained") + T.eq(raw.ivs.hp, 17, label .. "hidden IVs retained") + off() + + -- Mutations across separate bound records preflight as one transaction. + local other = mon(ids) + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.hp = 4 + ctx.target.mon.level = 101 + return 99 + end) + count = 0 + T.eq( + loader.hooks:call("battle.damage", function() + count = count + 1 + return 3 + end, { user = battler, target = { mon = other } }), + 3, + label .. "invalid edit skips wrapper" + ) + T.eq(raw.hp, 18, label .. "earlier record edit rolled back") + T.eq(other.level, 10, label .. "invalid record unchanged") + T.eq(count, 1, label .. "failure before next falls through once") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + local n = next() + ctx.user.mon.hp = -1 + return n + 20 + end) + count = 0 + T.eq( + loader.hooks:call("battle.damage", function() + count = count + 1 + raw.hp = 16 + return 7 + end, { user = battler }), + 7, + label .. "bad post-next edit keeps downstream result" + ) + T.eq(raw.hp, 16, label .. "downstream mutation retained") + T.eq(count, 1, label .. "bad post-next never retries") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + next() + ctx.user.mon.hp = 2 + error("after next") + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 8 + end, { user = battler }), + 8, + label .. "throw after next preserves result" + ) + T.eq(raw.hp, 16, label .. "throw after next discards late edits") + off() + + local sentinel = { error = "vanilla" } + off = api.hooks:wrap("battle.damage", function(next) + pcall(next) + pcall(next) + return 999 + end) + count = 0 + local ok, err = pcall(function() + loader.hooks:call("battle.damage", function() + count = count + 1 + error(sentinel) + end, { user = battler }) + end) + T.check(not ok and err == sentinel, label .. "vanilla error identity survives swallowed next failure") + T.eq(count, 1, label .. "throwing vanilla never replayed") + off() + off = api.hooks:wrap("battle.damage", function(next) + pcall(next) + return 999 + end) + local savedMoves = raw.moves + local okBroken, errBroken = pcall(function() + loader.hooks:call("battle.damage", function() + raw.moves = 42 -- a failed native operation can leave an unreadable record + error(sentinel) + end, { user = battler }) + end) + T.check(not okBroken and errBroken == sentinel, label .. "refresh cannot mask a native error") + raw.moves = savedMoves + off() + + -- Event records are detached. The native handles deliberately remain handles. + local seen = 0 + off = api.events:once("battle.exp_gained", function(payload) + seen = seen + 1 + T.eq(payload.mon.species, "ALPHA", label .. "event mon normalized") + payload.mon.hp = 1 + payload.mon.native.ivs.hp = 1 + end) + loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 }) + loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 }) + T.eq(seen, 1, label .. "once retained") + T.eq(raw.hp, 16, label .. "event mutation detached") + T.eq(raw.ivs.hp, 17, label .. "native diagnostic snapshot detached") + off() + local gift = { species = ids[1], level = 5, ctx = { opaque = true } } + off = api.events:on("pokemon.before_give", function(p) + p.species = "BETA" + p.level = 15 + p.nickname = "Gift" + end) + loader.events:emit("pokemon.before_give", gift) + T.eq(gift.species, ids[2], label .. "gift ID restored") + T.eq(gift.level, 15, label .. "gift level committed") + T.eq(gift.nickname, "Gift", label .. "gift nickname committed") + off() + off = api.events:on("pokemon.before_give", function(p) + p.species = "ALPHA" + p.level = 101 + end) + loader.events:emit("pokemon.before_give", gift) + T.eq(gift.species, ids[2], label .. "invalid gift atomic species") + T.eq(gift.level, 15, label .. "invalid gift atomic level") + off() + + off = api.hooks:wrap("battle.turn_order", function(next, p, pm, e, em, ctx) + T.eq(pm.id, "TACKLE", label .. "move definition canonical ID") + T.eq(next(), p, label .. "native result maps to original public battler") + return e + end) + local enemy = { mon = other, isPlayer = false } + T.eq( + loader.hooks:call("battle.turn_order", function(p) + return p + end, battler, data.moves[ids[3]], enemy, data.moves[ids[4]], {}), + enemy, + label .. "selected battler restores identity" + ) + off() + off = api.hooks:wrap("encounter.species", function(next, enc) + enc.species = "BETA" + enc.level = 20 + return next() + end) + local encounter = { species = ids[1], level = 10, form = 2 } + T.eq( + loader.hooks:call("encounter.species", function(enc) + return enc + end, encounter, {}), + encounter, + label .. "encounter identity retained" + ) + T.eq(encounter.species, ids[2], label .. "encounter species converted") + T.eq(encounter.form, 2, label .. "encounter hidden field retained") + off() + off = api.hooks:wrap("trainer.party", function(next, cl, index, party) + T.eq(party[1].species, "ALPHA", label .. "trainer slot canonical species") + T.eq(party[1].moves[1], "TACKLE", label .. "trainer slot moves are IDs") + party[1].species = "BETA" + party[1].moves = { "EMBER" } + party[1].level = 20 + return next() + end) + local party = { { species = ids[1], level = 10, moves = { ids[3] }, dvs = { attack = 15 } } } + T.eq( + loader.hooks:call("trainer.party", function(_, _, p) + return p + end, "CLASS", 1, party), + party, + label .. "trainer party native identity" + ) + T.eq(party[1].species, ids[2], label .. "trainer species restored") + T.eq(party[1].moves[1], ids[4], label .. "trainer move restored") + T.eq(party[1].dvs.attack, 15, label .. "trainer hidden DVs retained") + off() + + count = 0 + off = api.hooks:wrap("battle.exp_award", function(_, ctx) + local p = ctx.alive[1] + p.hp = 15 + ctx.applyShare(p, 2, false) + T.eq(p.level, 11, label .. "applyShare refreshes public mon") + p.hp = 14 + end) + loader.hooks:call("battle.exp_award", function() + error("wrapper replaces vanilla") + end, { + battle = battle, + alive = { raw }, + applyShare = function(p, divisor, announce) + count = count + 1 + T.eq(p, raw, label .. "helper gets native identity") + T.eq(p.hp, 15, label .. "helper sees pending edits") + T.eq(divisor, 2, label .. "helper scalar args") + T.eq(announce, false, label .. "helper false arg") + p.level = 11 + end, + }) + T.eq(count, 1, label .. "helper called once") + T.eq(raw.hp, 14, label .. "helper postprocessing committed") + off() + + off = api.hooks:wrap("fieldmove.eligibility", function(next, id) + T.eq(id, "TACKLE", label .. "field move argument normalized") + local p = next() + T.eq(p.species, "ALPHA", label .. "field move result normalized") + return p + end) + T.eq( + loader.hooks:call("fieldmove.eligibility", function(id) + T.eq(id, ids[3], label .. "field move argument restored") + return raw + end, ids[3], {}), + raw, + label .. "field move mon result identity restored" + ) + off() + off = api.hooks:wrap("battle.enemy_action", function(next) + local action = next() + T.eq(action.id, "TACKLE", label .. "enemy action normalized") + action.id = "EMBER" + return action + end) + local action = { id = ids[3], index = 1 } + T.eq( + loader.hooks:call("battle.enemy_action", function() + return action + end, battle, battler), + action, + label .. "enemy action identity restored" + ) + T.eq(action.id, ids[4], label .. "enemy action ID restored") + off() + off = api.hooks:wrap("encounter.roll", function() + return { species = "BETA", level = 30 } + end) + local enc = loader.hooks:call("encounter.roll", function() + error("suppressed") + end, nil, {}) + T.eq(enc.species, ids[2], label .. "new encounter result native species") + T.eq(enc.level, 30, label .. "new encounter level") + off() + off = api.hooks:wrap("encounter.roll", function() + return { species = "MISSING", level = 30 } + end) + T.eq( + loader.hooks:call("encounter.roll", function() + return "fallback" + end, nil, {}), + "fallback", + label .. "unknown species rejected" + ) + off() + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.move.power = 999 + return 88 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 4 + end, { move = data.moves[ids[3]] }), + 4, + label .. "definition mutation rejected" + ) + T.eq(data.moves[ids[3]].power, 40, label .. "definition unchanged") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + ctx.user.mon.maxHp = 50 + ctx.user.mon.stats.specialAttack = 28 + next() + return 0 + end) + loader.hooks:call("battle.damage", function() + T.eq(raw.stats.hp, 50, label .. "maxHp writes native stats.hp") + T.eq(raw.stats.special, 28, label .. "special alias kept") + T.eq( + Version.generation() == 1 and raw.stats.special or raw.stats.spatk, + 28, + label .. "split special stat writes correctly" + ) + end, { user = battler }) + off() + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.native.ivs.hp = 9 + ctx.user.mon.hp = 2 + return 88 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 3 + end, { user = battler }), + 3, + label .. "native snapshot mutation rejected" + ) + T.eq(raw.hp, 14, label .. "snapshot rejection atomic") + off() + local nativeCalls = 0 + local undoNative = loader.hooks:wrap("battle.damage", function(next, ctx) + nativeCalls = nativeCalls + 1 + T.eq(ctx.user, battler, label .. "native mod sees native battler") + return next() + end, -5, "native") + off = api.hooks:wrap("battle.damage", function(next, ctx) + -- Explicit argument packs accept bound records inside a bound context. + ctx.extra = 123 + return next(ctx) + end, 5) + loader.hooks:call("battle.damage", function(ctx) + T.eq(ctx.extra, 123, label .. "explicit next args committed") + end, { user = battler }) + T.eq(nativeCalls, 1, label .. "mixed native/unified chain") + off() + undoNative() + local order = {} + local a = api.events:on("battle.started", function() + order[#order + 1] = "low" + end, -1) + local b = api.events:on("battle.started", function() + order[#order + 1] = "high" + end, 1) + loader.events:emit("battle.started", {}) + T.same(order, { "high", "low" }, label .. "event priority retained") + a() + b() + local actionView + off = api.events:on("battle.turn_started", function(p) + actionView = p.playerAction + end) + loader.events:emit("battle.turn_started", { playerAction = { id = ids[3], index = 1 } }) + T.eq(actionView.id, "TACKLE", label .. "turn event action ID normalized") + off() + + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.moves = { [2] = { id = "TACKLE", pp = 1, ppUps = 0 } } + ctx.user.mon.hp = 1 + return 99 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 2 + end, { user = battler }), + 2, + label .. "sparse move list rejected" + ) + T.eq(raw.hp, 14, label .. "sparse move list fails atomically") + off() + local retainedNext, retainedShare + off = api.hooks:wrap("battle.exp_award", function(next, ctx) + retainedNext = next + retainedShare = ctx.applyShare + end) + loader.hooks:call("battle.exp_award", function() + error("must not run") + end, { + alive = { raw }, + applyShare = function() + error("must not run") + end, + }) + T.raises(function() + retainedNext() + end, "only valid during", label .. "retained next cannot run after callback") + T.raises(function() + retainedShare(raw) + end, "bound Pokemon", label .. "helper rejects native mon") + off() + local heldMon + off = api.hooks:wrap("battle.exp_award", function(_, ctx) + heldMon = ctx.alive[1] + retainedShare = ctx.applyShare + end) + loader.hooks:call("battle.exp_award", function() end, { + alive = { raw }, + applyShare = function() + error("must not run") + end, + }) + T.raises(function() + retainedShare(heldMon) + end, "closed", label .. "retained helper cannot write after callback") + off() + + local custom = { mon = raw } + local got + off = api.events:on("mod.other.ready", function(p) + got = p + end) + loader.events:emit("mod.other.ready", custom) + T.eq(got, custom, label .. "custom payload identity untouched") + off() + T.raises(function() + api.events:emit("mod.other.ready", {}) + end, "only emit", label .. "cannot spoof other mod") + T.raises(function() + api.events:emit("mod.probe.", {}) + end, "only emit", label .. "empty custom suffix rejected") + T.raises(function() + api.hooks:wrap("invented.hook", function() end) + end, "unknown unified", label .. "unknown hook rejected") + T.raises(function() + api.events:on("invented.event", function() end) + end, "unknown unified", label .. "unknown event rejected") +end +-- The native path must not install any wrapper or copy its payloads. +local api, loader = boot("crystal") +T.eq(api.callbackFormat, "native", "default API native") +local raw = { mon = { species = 42 } } +api.events:on("custom.engine", function(p) + T.eq(p, raw, "native event identity unchanged") +end) +loader.events:emit("custom.engine", raw) +api.hooks:wrap("custom.hook", function(next, ctx) + T.eq(ctx, raw, "native hook identity unchanged") + return next() +end) +T.eq( + loader.hooks:call("custom.hook", function(ctx) + return ctx + end, raw), + raw, + "native result identity unchanged" +) +-- Entry rollback removes unified subscriptions using the same native owner. +local _, failed = boot( + "crystal", + "unified", + [[return function(mod) + mod.events:on('battle.started',function() end) + mod.hooks:wrap('battle.damage',function() end) + error('rollback probe') +end]] +) +T.check(#failed.errors > 0, "failed entry attributed") +T.check( + not failed.events.listeners["battle.started"] or #failed.events.listeners["battle.started"] == 0, + "failed entry event removed" +) +T.check( + not failed.hooks.chains["battle.damage"] or #failed.hooks.chains["battle.damage"] == 0, + "failed entry hook removed" +) +Version.set(saved[1]) +Runtime.install(saved[2], saved[3], saved[4]) +love = saved[5] +T.finish() From 886fca58204c5043f31a6d8f5e325c3cd0d3d4bf Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:17:55 +0200 Subject: [PATCH 04/71] Add detached read-only world and party queries --- docs/modding.md | 8 + docs/world-read-api.md | 151 ++++++++++++ src/mods/Loader.lua | 13 +- src/mods/UnifiedIds.lua | 85 +++++++ src/mods/WorldReads.lua | 227 +++++++++++++++++ src/world/WorldAPI.lua | 27 ++- tests/engine/world_read_api.lua | 415 ++++++++++++++++++++++++++++++++ 7 files changed, 923 insertions(+), 3 deletions(-) create mode 100644 docs/world-read-api.md create mode 100644 src/mods/UnifiedIds.lua create mode 100644 src/mods/WorldReads.lua create mode 100644 tests/engine/world_read_api.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..8743187dd 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -70,6 +70,14 @@ is a normal answer rather than a failure, and telling it apart from per frame, and a directory walk over several hundred blobs per frame is not something a menu can afford. +## Read-only world and party queries + +`mod.world:party()`, `activeBlockAt(mapId, bx, by)` and `liveMaps()` expose +common Pokémon snapshots and current runtime map data across generations +1–5. Reads return detached data and do not load maps or change gameplay. +See [record fields, coordinate units and DS limitations](world-read-api.md). +The existing `loadedMaps()` method retains its legacy shape. + ## Editing maps in Tiled Maps are data, not assets, so they can be authored in a real map editor and diff --git a/docs/world-read-api.md b/docs/world-read-api.md new file mode 100644 index 000000000..01b2a60d0 --- /dev/null +++ b/docs/world-read-api.md @@ -0,0 +1,151 @@ +# Read-only world and party queries + +Native mods can read `mod.world:party()`, `activeBlockAt(mapId, bx, by)` and +`liveMaps()` across generations 1–5. These are additive methods: no manifest +format switch or Mod API bump is required. Their records use the common live +Pokémon shape from unified callbacks, independently of the mod's selected +content/callback format. Existing world actions and `loadedMaps()` retain +their previous behavior. + +The methods read the current save/runtime map when called. They do not load +maps, decode whole packed grids, create Pokémon, emit events, write state or +advance random generators. Returned tables are detached plain data; editing +or retaining them never changes the game. Native engine handles remain +available through the existing APIs, but these three methods return none. + +```lua +return function(mod) + mod.events:on("world.stepped", function() + local here = mod.world:current() + if not here then return end + + local party, reason = mod.world:party() + if not party then mod.log:warn("%s", reason); return end + + local maps = mod.world:liveMaps() + local geometry = maps and maps[1].block + if geometry then + -- current().x/y use walk cells; activeBlockAt uses map blocks. + local cellsPerBlock = geometry.tileSize / geometry.cellSize + local block, info = mod.world:activeBlockAt(here.mapId, + math.floor(here.x / cellsPerBlock), math.floor(here.y / cellsPerBlock)) + if block ~= nil then mod.log:info("%s block %d", info.kind, block) end + end + end) +end +``` + +## Party snapshots + +`party()` returns an array in native party order. A save with an empty or +absent party gives `{}`; no save gives `nil, "no save"`. Reading a saved party +does not require an active overworld, so menus and a title-screen save are +valid contexts. A retained facade observes a replacement of `game.save` on +its next call; previously returned records remain snapshots. + +Each Pokémon has the following common fields (optional native values may +be absent): + +| Field | Meaning | +| --- | --- | +| `species`, `item` | Symbolic IDs; an absent/zero held item gives `nil` | +| `nickname`, `name` | Native nickname and optional native name | +| `level`, `hp`, `maxHp` | Current values; `maxHp` is native `stats.hp` | +| `status` | `sleep`, `poison`, `burn`, `freeze`, `paralyze`, `toxic`, or absent; unknown native status labels are retained | +| `isEgg` | Native egg flag as a boolean | +| `stats` | `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, `specialDefense` | +| `moves` | Array of `{ id, pp, maxPp, ppUps }`; absent definitions leave `maxPp` absent | +| `native` | Detached data-only diagnostics, including IVs/EVs and custom save fields | + +IDs use this boot's actual registries, including custom records and numeric +DS IDs, rather than hard-coded National Dex numbers. Numeric IDs resolve via +imported names; existing named keys retain their names. Standard aliases use +the shared unified-ID resolver. Identity/name metadata is captured before +mod merging, so a display-name patch does not rename a canonical ID. Records +registered later during mod initialization remain visible. Unknown IDs are +preserved as strings; this is a read-only view, not an allocation or reference +validation API. The native diagnostics retain the underlying numeric IDs. + +Gen 1's single special stat fills both special-attack and special-defense +fields; later split stats read native `spatk` and `spdef`. PP maxima use native +PP-Up rounding (`base + ppUps * floor(base / 5)`). Reads never recalculate +stats, learn moves, infer shininess or consume RNG. Functions, userdata, +threads, metatables and table-valued keys are excluded from diagnostics; +cycles in data are copied without retaining native tables. + +## Active block lookup + +`activeBlockAt(mapId, bx, by)` returns `blockId, metadata`, or `nil, reason`. +The expected map ID is required: a request prepared before a warp cannot +silently inspect the replacement map. Only the active runtime map is read. +Coordinates are finite, nonnegative integers in **zero-based block units**; +off-map coordinates are refused rather than returning the map's border fill. +A block ID of zero is a valid result: check `blockId ~= nil`. + +Runtime `blockPatch` entries take precedence, including zero-valued changes. +Table grids, packed little-endian 16-bit grids and an existing decoded cache +follow the runtime Map representation. A cold packed read extracts only the +requested word and its low ten ID bits; it does not populate `_blockArray`. +Missing/truncated data, negative/invalid IDs and inconsistent geometry give +`nil, reason`. No call into map loading, rendering or collision is made. + +Metadata is a fresh record: + +| Field | Units / meaning | +| --- | --- | +| `tileSize` | Pixels per map block, from runtime `blockTiles * 8` | +| `cellSize` | 16 pixels per walk cell | +| `coordinateBase` | `0` | +| `width`, `height` | Map dimensions in blocks | +| `kind` | `block`, `metatile`, or `runtime_cell` | + +Ordinary Gen 1/2 blocks are 32 pixels; Gen 3 metatiles are 16 pixels. Geometry +comes from the actual runtime tileset so custom maps need not match their +selected generation's usual size. Gen 4/5 imported maps use a 16-pixel proxy +grid in the shared runtime; `runtime_cell` explicitly identifies that grid. +Its values are synthetic runtime/behavior values, **not DS mesh/model IDs or +a public collision/elevation encoding**. Use the existing terrain/elevation +services for those questions. A DS map must carry the runtime grid; this +method does not generate one from meshes or lazily load shared layouts. + +## Loaded map snapshots + +`liveMaps()` returns only maps already held by the active overworld, with the +active map first and neighbors in native order. It never enumerates all ROM +maps or loads missing neighbors. No overworld gives `nil, "no overworld"`. +Each row contains: + +```lua +{ + mapId = "MAP_ID", active = true, + x = 0, y = 0, -- placement relative to active map, walk cells + width = 20, height = 18, -- dimensions in walk cells + block = { -- absent if runtime geometry is unavailable + tileSize = 32, cellSize = 16, coordinateBase = 0, + width = 10, height = 9, kind = "block" -- dimensions in blocks + } +} +``` + +Neighbor offsets may be negative. Exact duplicate placements collapse; +distinct placements of one map at different offsets remain separate rows. +Each row carries its own geometry, including mixed-size neighborhoods. +The legacy `loadedMaps()` method keeps its original shape, duplicates and +order for existing consumers. + +## Availability and tests + +When the optional `mod:supports` capability-query feature is installed, +`mod:supports("world", "party")`, `activeBlockAt` and `liveMaps` report true. +Probes do not construct Game or a world. Availability describes the method; +a call can still return `no save`, `no overworld`, `map is not active`, invalid +coordinates, unavailable geometry or unavailable data. Other capability +answers retain their existing behavior. + +`tests/engine/world_read_api.lua` exercises the real loader and Map objects +without an imported ROM or a graphics context, across all supported version +IDs. It checks detached nested data, ID aliases and late registrations, +current save/map replacement, block sizes/bounds, cold/warm packed data, +runtime patches, map placements, native `loadedMaps()` parity and RNG/gameplay +tripwires. When the unified-callback feature is present, it also compares the +party shape against an actual event's Pokémon record. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..8ea12fc6e 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -20,6 +20,7 @@ local Semver = require("src.mods.Semver") local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") local ModStorage = require("src.mods.Storage") +local WorldReads = require("src.mods.WorldReads") local Runtime = require("src.mods.Runtime") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") @@ -1125,6 +1126,16 @@ function Loader:_api(mod) -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. local world, storage + local readContext = WorldReads.context(loader) + -- Capability queries are optional on older API-2 hosts. When present, + -- report the additive readers without constructing Game or the world. + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and WorldReads.supports(name) then return true end + return supports(self, surface, name) + end + end setmetatable(api, { __index = function(_, key) if key == "game" then return loader:_game() end if key == "storage" then @@ -1139,7 +1150,7 @@ function Loader:_api(mod) local game = loader:_game() local module = game and engineRequire("src.world.WorldAPI") if not module then return nil end - world = module.new(game, modId) + world = module.new(game, modId, readContext) return world end }) return api diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/src/mods/WorldReads.lua b/src/mods/WorldReads.lua new file mode 100644 index 000000000..2ca8f2504 --- /dev/null +++ b/src/mods/WorldReads.lua @@ -0,0 +1,227 @@ +-- Data-only readers shared by mod.world. Nothing here constructs a Pokemon, +-- loads a map, calls gameplay methods or advances either random generator. +local Ids = require("src.mods.UnifiedIds") +local Reads = {} +local METHODS = { party = true, activeBlockAt = true, liveMaps = true } + +function Reads.supports(name) + return type(name) == "string" and METHODS[name] == true +end + +-- Capture native alias identity/name metadata before the loader splits alias +-- rows during merging. Fresh indexes at read time still include later mods. +function Reads.context(loader) + local ids = Ids.new(loader) + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + ids:entries(kind) + end + return { + ids = function() + return Ids.new(loader) + end, + } +end + +-- Engine callers can construct WorldAPI without a mod loader too. The +-- private registry facade only reads data; Ids' bookkeeping stays on it. +local function dataIds(data) + local loader = { content = {} } + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + local rows = type(data[kind]) == "table" and data[kind] or {} + loader.content[kind] = { + base = function() + return rows + end, + each = function() + return pairs(rows) + end, + } + end + return Ids.new(loader) +end + +local function copy(value, seen) + local kind = type(value) + if kind ~= "table" then + if kind == "number" or kind == "string" or kind == "boolean" then + return value + end + return nil + end + seen = seen or {} + if seen[value] then + return seen[value] + end + local out = {} + seen[value] = out + for key, sub in pairs(value) do + if type(key) == "string" or type(key) == "number" then + out[key] = copy(sub, seen) + end + end + return out +end +local STATUS = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local STAT = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} + +-- The same common live-Pokemon shape as the unified callback boundary. +-- Native diagnostics are data-only copies, including unknown save extensions. +function Reads.party(game, context) + local save = game and game.save + if not save then + return nil, "no save" + end + local data = game.data or {} + local ids = context and context.ids() or dataIds(data) + local out = {} + for i, raw in ipairs(save.party or {}) do + local stats, moves = {}, {} + for key, native in pairs(STAT) do + stats[key] = (raw.stats or {})[native] + end + stats.specialAttack = stats.specialAttack or stats.special + stats.specialDefense = stats.specialDefense or stats.special + for slot, move in ipairs(raw.moves or {}) do + local id = type(move) == "table" and move.id or move + local def = (data.moves or {})[id] + local ups = type(move) == "table" and (move.ppUps or 0) or 0 + moves[slot] = { + id = ids:read("moves", id), + pp = type(move) == "table" and move.pp or nil, + maxPp = def and ((def.pp or 0) + ups * math.floor((def.pp or 0) / 5)) or nil, + ppUps = ups, + } + end + out[i] = { + species = ids:read("pokemon", raw.species), + item = ids:read("items", raw.item), + nickname = raw.nickname, + name = raw.name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = STATUS[raw.status] or raw.status, + isEgg = raw.isEgg == true, + stats = stats, + moves = moves, + native = copy(raw), + } + end + return out +end + +local function integer(value, low) + return type(value) == "number" + and value == value + and value < math.huge + and value > -math.huge + and value % 1 == 0 + and value >= low +end + +-- Public block coordinates are zero-based, not walk-cell coordinates. Derive +-- geometry from this map's runtime tileset, not the selected game's number. +function Reads.metadata(map) + local def = map and map.def + if + not def + or not integer(def.width, 1) + or not integer(def.height, 1) + or not integer(map.blockCells, 1) + or not integer(map.blockTiles, 1) + or map.blockTiles * 8 ~= map.blockCells * 16 + then + return nil, "block geometry unavailable" + end + return { + tileSize = map.blockTiles * 8, + cellSize = 16, + coordinateBase = 0, + kind = (def.generation == 4 or def.generation == 5 or def.gen5) and "runtime_cell" + or map.blockCells == 1 and "metatile" + or "block", + width = def.width, + height = def.height, + } +end + +function Reads.activeBlockAt(map, mapId, bx, by) + if map.id ~= mapId then + return nil, "map is not active" + end + if not integer(bx, 0) or not integer(by, 0) then + return nil, "invalid block coordinates" + end + local metadata, reason = Reads.metadata(map) + if not metadata then + return nil, reason + end + if bx >= metadata.width or by >= metadata.height then + return nil, "block coordinates out of bounds" + end + local index = by * metadata.width + bx + 1 + local block = map.blockPatch and map.blockPatch[index] + if block == nil then + local blocks = map.def.blocks + if type(blocks) == "table" then + block = blocks[index] + elseif type(blocks) == "string" then + -- Respect a cache already used by Map:blockAt, without populating it. + if map.def._blockArray then + block = map.def._blockArray[index] + else + local lo, hi = blocks:byte(index * 2 - 1, index * 2) + if hi then + block = (lo + hi * 256) % 1024 + end + end + end + end + if not integer(block, 0) then + return nil, "block unavailable" + end + return block, metadata +end + +function Reads.liveMaps(world) + local active = world.map + local out = {} + local seen = {} + local function add(map, x, y, isActive) + if not map then + return + end + -- A connected layout can show one map at several different offsets. + -- Collapse duplicate placements only, retaining the spatial instances. + local key = tostring(map.id) .. ":" .. tostring(x) .. ":" .. tostring(y) + if seen[key] then + return + end + seen[key] = true + local metadata = Reads.metadata(map) + out[#out + 1] = { + mapId = map.id, + x = x, + y = y, + active = isActive, + width = map.widthCells, + height = map.heightCells, + block = metadata, + } + end + add(active, 0, 0, true) + for _, neighbor in ipairs(world.neighbors or {}) do + add(neighbor.map, neighbor.cx or 0, neighbor.cy or 0, false) + end + return out +end +return Reads diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index f21419ae9..9d7a1cdea 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -9,14 +9,15 @@ local Logger = require("src.core.Logger") local MapLoader = require("src.world.MapLoader") local Gen3Elevation = require("src.world.Gen3Elevation") local Runtime = require("src.mods.Runtime") +local Reads = require("src.mods.WorldReads") local WorldAPI = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" -function WorldAPI.new(game, modId) - return setmetatable({ game = game, modId = modId }, WorldAPI) +function WorldAPI.new(game, modId, readContext) + return setmetatable({ game = game, modId = modId, readContext = readContext }, WorldAPI) end -- the live overworld, or nil. Game.overworld is the fast path; the stack @@ -58,6 +59,28 @@ function WorldAPI:current() elevation) } end +-- Detached common Pokemon records. This also works with a save at the title +-- screen: reading the party does not need an active overworld. +function WorldAPI:party() + return Reads.party(self.game, self.readContext) +end + +-- Require the expected active map to reject stale requests after a warp. +-- The second result describes block size/coordinates and the DS proxy grid. +function WorldAPI:activeBlockAt(mapId, bx, by) + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return Reads.activeBlockAt(ow.map, mapId, bx, by) +end + +-- Loaded maps only, active first and with distinct placements. loadedMaps() +-- retains its legacy shape/order; these records also describe block units. +function WorldAPI:liveMaps() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return Reads.liveMaps(ow) +end + -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else -- lands the player without one, like a scripted warp. function WorldAPI:warpTo(mapId, x, y, facing, opts) diff --git a/tests/engine/world_read_api.lua b/tests/engine/world_read_api.lua new file mode 100644 index 000000000..b7beda07f --- /dev/null +++ b/tests/engine/world_read_api.lua @@ -0,0 +1,415 @@ +-- ROM-free tests through the real loader and real runtime Map objects. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local WorldAPI = require("src.world.WorldAPI") +local Map = require("src.world.Map") +local MapLoader = require("src.world.MapLoader") +local Merge = require("src.mods.Merge") +local Runtime = require("src.mods.Runtime") +local GameVersion = require("src.core.GameVersion") +local Json = require("src.link.Json") +local Reads = require("src.mods.WorldReads") +local savedLove, savedVersion = love, GameVersion.get() +local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks, Runtime.errors +love = nil +local function pack16(...) + local out = {} + for _, v in ipairs({ ... }) do + out[#out + 1] = string.char(v % 256, math.floor(v / 256)) + end + return table.concat(out) +end +local function map(id, cells, packed, ds) + return Map.new({ + id = id, + width = 3, + height = 2, + blocks = packed and pack16(0, 1 + 3 * 1024, 2 + 9 * 4096, 3, 4, 1023) or { 0, 1, 2, 3, 4, 1023 }, + borderBlock = 999, + generation = ds and 4 or nil, + }, { blockCells = cells, blockTiles = cells * 2, walkable = {}, blocks = {} }) +end +local function boot(version, entry) + GameVersion.set(version) + local gen = GameVersion.generation() + local s, m, item = gen >= 4 and 25 or "PIKACHU", gen >= 4 and 33 or "TACKLE", gen >= 4 and 13 or "POTION" + local species = { id = s, name = "Pikachu" } + local move = { id = m, name = "Tackle", pp = 35 } + local data = { + pokemon = { [s] = species }, + moves = { [m] = move }, + items = { + [item] = { id = item, name = "Potion" }, + }, + } + if gen >= 4 then + data.pokemon.SPECIES_25 = species + data.moves.MOVE_33 = move + end + local mon = { + species = s, + item = item, + nickname = "Buddy", + name = "native name", + level = 7, + hp = 19, + status = "PSN", + stats = { hp = 23, attack = 10, defense = 11, speed = 12, special = 13 }, + moves = { { id = m, pp = 29, ppUps = 1, hidden = false } }, + ivs = { hp = 27 }, + evs = { speed = 3 }, + personality = 12345, + extension = { flag = false }, + isEgg = false, + } + if gen > 1 then + mon.stats.spatk = 13 + mon.stats.spdef = 14 + end + local active = map("ACTIVE", gen <= 2 and 2 or 1, gen >= 3, gen >= 4) + local neighbor = map("NORTH", gen <= 2 and 2 or 1, gen >= 3, gen >= 4) + local ow = { isOverworld = true, map = active, neighbors = { { map = neighbor, cx = -2, cy = -4 } } } + local game = { + data = data, + save = { party = { mon }, money = 500 }, + stack = { states = { ow, { isMenu = true } } }, + overworld = ow, + } + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function() + resolves = resolves + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + local api = assert(loader.apis.probe) + return api, loader, game, ow, { s, m, item }, function() + return resolves + end +end + +-- Fail if any reader tries to load a map, generate a Pokemon or use RNG. +local oldMapLoad = MapLoader.load +local Pokemon = require("src.pokemon.Pokemon") +local oldNew = Pokemon.new +local oldRandom = math.random +local function forbidden() + error("read crossed into gameplay") +end +MapLoader.load, Pokemon.new, math.random = forbidden, forbidden, forbidden + +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, game, ow, ids, resolves = boot(version) + local label = version .. ": " + T.eq(resolves(), 0, label .. "entry and read metadata do not instantiate Game") + if api.supports then + for _, name in ipairs({ "party", "liveMaps", "activeBlockAt" }) do + T.eq(api:supports("world", name), true, label .. "capability " .. name) + end + T.eq(resolves(), 0, label .. "capability probes do not instantiate Game") + end + local before = Merge.deepCopy(game) + local world = api.world + T.eq(api.world, world, label .. "same cached facade") + local party = world:party() + local mon = party[1] + T.eq(mon.species, "PIKACHU", label .. "symbolic species") + T.eq(mon.item, "POTION", label .. "symbolic item") + T.eq(mon.moves[1].id, "TACKLE", label .. "symbolic move") + T.eq(mon.moves[1].pp, 29, label .. "current PP") + T.eq(mon.moves[1].maxPp, 42, label .. "PP Ups use native rounding") + T.eq(mon.moves[1].ppUps, 1, label .. "PP Ups exposed") + T.eq(mon.maxHp, 23, label .. "max HP from stats.hp") + T.eq(mon.stats.specialAttack, 13, label .. "special attack") + T.eq(mon.stats.specialDefense, GameVersion.generation() == 1 and 13 or 14, label .. "special defense") + T.eq(mon.status, "poison", label .. "status normalized") + T.eq(mon.isEgg, false, label .. "false flags preserved") + T.eq(mon.native.extension.flag, false, label .. "native false extension preserved") + T.eq(mon.native.ivs.hp, 27, label .. "native IV diagnostics") + T.eq(getmetatable(mon), nil, label .. "plain detached mon") + mon.species = "OTHER" + mon.stats.hp = 1 + mon.moves[1].pp = 0 + mon.native.ivs.hp = 0 + party[2] = { species = "INJECTED" } + T.same(game, before, label .. "party mutations do not reach game") + local nextParty = world:party() + T.eq(nextParty[1].stats.hp, 23, label .. "fresh nested stat snapshot") + T.eq(nextParty[1].moves[1].pp, 29, label .. "fresh move snapshot") + T.eq(#nextParty, 1, label .. "party membership detached") + game.save.party[1].hp = 18 + T.eq(world:party()[1].hp, 18, label .. "reads current native HP") + game.save = { party = { { species = ids[1], hp = 1, stats = { hp = 2 }, moves = {} } } } + T.eq(world:party()[1].hp, 1, label .. "held facade follows a replaced save") + game.save = before.save + + local cells = ow.map.blockCells + before = Merge.deepCopy(game) + local id, metadata = world:activeBlockAt("ACTIVE", 0, 0) + T.eq(id, 0, label .. "block zero is present") + T.eq(metadata.tileSize, cells * 16, label .. "block pixel size from runtime map") + T.eq(metadata.cellSize, 16, label .. "walk-cell pixel size") + T.eq(metadata.coordinateBase, 0, label .. "zero-based block coordinates") + T.eq(metadata.width, 3, label .. "block width") + T.eq(metadata.height, 2, label .. "block height") + local gen = GameVersion.generation() + T.eq( + metadata.kind, + gen >= 4 and "runtime_cell" or gen >= 3 and "metatile" or "block", + label .. "honest block kind" + ) + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 1, label .. "packed collision bits stripped") + T.eq(world:activeBlockAt("ACTIVE", 2, 0), 2, label .. "packed elevation bits stripped") + T.eq(world:activeBlockAt("ACTIVE", 2, 1), 1023, label .. "last block / full 10-bit ID") + T.eq(ow.map.def._blockArray, nil, label .. "cold packed grid remains undecoded") + metadata.width = 999 + T.eq(select(2, world:activeBlockAt("ACTIVE", 0, 0)).width, 3, label .. "metadata detached") + for _, xy in ipairs({ + { -1, 0 }, + { 0, -1 }, + { 3, 0 }, + { 0, 2 }, + { 0.5, 0 }, + { 0, 0.5 }, + { math.huge, 0 }, + { + 0, + 0 / 0, + }, + { + "1", + 0, + }, + }) do + local value, reason = world:activeBlockAt("ACTIVE", xy[1], xy[2]) + T.check(value == nil and type(reason) == "string", label .. "invalid/off-map coordinates rejected") + end + local missing, reason = world:activeBlockAt("NORTH", 0, 0) + T.check(missing == nil and reason == "map is not active", label .. "stale map rejected") + T.same(game, before, label .. "block reads do not alter world or decoder cache") + ow.map:setBlock(1, 0, 0) + local patched = Merge.deepCopy(game) + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 0, label .. "live zero-valued block patch wins") + T.same(game, patched, label .. "patch read remains pure") + ow.map.blockPatch[2] = 777 + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 777, label .. "reads current runtime patch") + local oldMap = ow.map + ow.map = ow.neighbors[1].map + T.eq(world:activeBlockAt("ACTIVE", 0, 0), nil, label .. "held facade rejects map after warp") + T.eq(world:activeBlockAt("NORTH", 0, 0), 0, label .. "held facade reads new active map") + ow.map = oldMap + + before = Merge.deepCopy(game) + local legacy = world:loadedMaps() + local maps = world:liveMaps() + T.eq(#maps, 2, label .. "only loaded maps returned") + T.eq(maps[1].mapId, "ACTIVE", label .. "active first") + T.eq(maps[1].active, true, label .. "active tagged") + T.eq(maps[1].width, 3 * cells, label .. "map dimensions in cells") + T.eq(maps[2].mapId, "NORTH", label .. "loaded neighbor included") + T.eq(maps[2].x, -2, label .. "neighbor x offset in cells") + T.eq(maps[2].y, -4, label .. "neighbor y offset in cells") + T.eq(maps[2].block.tileSize, cells * 16, label .. "per-map block units") + maps[1].mapId = "MUTATED" + maps[2].block.width = 9 + maps[2].x = 123 + T.same(game, before, label .. "live map snapshots detached") + T.same(world:loadedMaps(), legacy, label .. "legacy loadedMaps shape unchanged") + T.eq(world:liveMaps()[2].x, -2, label .. "fresh map snapshots") + + game.stack.states = {} + game.overworld = nil + T.eq(world:activeBlockAt("ACTIVE", 0, 0), nil, label .. "no active world block result") + T.eq(select(2, world:activeBlockAt("ACTIVE", 0, 0)), "no overworld", label .. "no world reason") + T.eq(world:liveMaps(), nil, label .. "no active world maps result") + T.check(world:party() ~= nil, label .. "party available without world") + game.save = nil + T.eq(world:party(), nil, label .. "no save party result") + T.eq(select(2, world:party()), "no save", label .. "no save reason") +end + +MapLoader.load, Pokemon.new, math.random = oldMapLoad, oldNew, oldRandom +-- Trap LÖVE's RNG too when the host provides one; queries remain ordinary +-- data reads in a host with no graphics/filesystem surface. +love = { math = { random = forbidden } } +local rngApi = WorldAPI.new( + { data = {}, save = { party = {} }, overworld = { isOverworld = true, map = map("RNG", 1, true, false) } }, + "rng" +) +T.check(rngApi:party() ~= nil, "party never calls love.math.random") +T.eq(rngApi:activeBlockAt("RNG", 0, 0), 0, "block never calls love.math.random") +T.eq(#rngApi:liveMaps(), 1, "map list never calls love.math.random") +love = nil +-- Direct engine construction also produces symbolic records without a loader. +local data = { + pokemon = { [999] = { name = "Mr. Mime" } }, + moves = { [888] = { name = "Ember", pp = 25 } }, + items = { + [777] = { name = "Potion" }, + }, +} +local raw = { + species = 999, + item = 777, + hp = 3, + stats = { hp = 9 }, + moves = { { id = 888, pp = 5 } }, + extension = { + flag = false, + }, +} +local direct = WorldAPI.new({ data = data, save = { party = { raw } } }, "direct") +local before = Merge.deepCopy(data) +local p = direct:party() +T.eq(p[1].species, "MR_MIME", "standalone numeric species resolves by imported name") +T.eq(p[1].moves[1].id, "EMBER", "standalone numeric move resolves") +T.eq(p[1].native.extension.flag, false, "standalone false diagnostics retained") +T.same(data, before, "standalone resolver does not mutate data") +raw.self = raw +raw.callback = function() end +raw[{}] = { hidden = true } +local cyclic = direct:party()[1] +T.eq(cyclic.native.self, cyclic.native, "cyclic diagnostics detached consistently") +T.eq(cyclic.native.callback, nil, "diagnostics do not expose functions") +local tableKey = false +for k in pairs(cyclic.native) do + if type(k) == "table" then + tableKey = true + end +end +T.eq(tableKey, false, "diagnostics do not retain native table keys") +raw.self = nil +raw.callback = nil + +-- Metadata comes from the actual tileset even when its size is unusual. +local mixed = map("MIXED", 3, false, false) +local mixedApi = WorldAPI.new({ overworld = { isOverworld = true, map = mixed } }, "mixed") +T.eq( + select(2, mixedApi:activeBlockAt("MIXED", 0, 0)).tileSize, + 48, + "custom block size is not inferred from generation" +) +local missingMap = map("SHORT", 1, true, false) +missingMap.def.blocks = "\1" +local shortApi = WorldAPI.new({ overworld = { isOverworld = true, map = missingMap } }, "short") +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "truncated packed block refused") +missingMap.def.blocks = {} +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "missing array cell refused") +missingMap.def.blocks = { -1 } +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "negative block ID refused") +missingMap.def.blocks = pack16(1) +missingMap.def._blockArray = { 7 } +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), 7, "warm decoder cache matches Map:blockAt") +local cache = missingMap.def._blockArray +T.eq(cache[1], 7, "warm cache unmodified") +missingMap.blockTiles = 3 +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "inconsistent runtime geometry refused") +local placements = { isOverworld = true, map = map("HOME", 1, false, false) } +local repeated = map("NORTH", 1, false, false) +placements.neighbors = { + { map = repeated, cx = 0, cy = -2 }, + { map = repeated, cx = 0, cy = -2 }, + { map = repeated, cx = 3, cy = 0 }, + { map = placements.map, cx = 0, cy = 0 }, + { map = map("OTHER", 2, false, false), cx = -4, cy = 0 }, +} +local list = WorldAPI.new({ overworld = placements }, "placements"):liveMaps() +T.eq(#list, 4, "duplicate placements collapse, different placements of the same map remain") +T.eq(list[2].mapId, list[3].mapId, "two placements share map identity") +T.eq(list[3].x, 3, "second placement offset preserved") +T.eq(list[4].block.tileSize, 32, "mixed-neighborhood block sizes describe each map") +T.eq(list[4].width, 6, "mixed-neighborhood width remains in walk cells") +local empty = WorldAPI.new(nil, "empty") +T.eq(empty:party(), nil, "no Game party read safe") +T.eq(empty:liveMaps(), nil, "no Game map read safe") +T.eq(empty:activeBlockAt("MAP", 0, 0), nil, "no Game block read safe") + +-- IDs stay bound to pre-merge identity despite presentation patches and +-- registrations made after the API/context was constructed. +local api, loader, game = boot( + "platinum", + [[return function(mod) + mod.exports.api=mod + mod.content.pokemon:patch("SPECIES_25",{name="A localized display name"}) + mod.content.pokemon:register("CUSTOMMON",{ + id="CUSTOMMON",dex=999,name="Another display name",types={"NORMAL"}, + catchRate=45,baseExp=64,level1Moves={},growthRate="MEDIUM_FAST", + learnset={},evolutions={},spriteFront="front.png",spriteBack="back.png",frontSize=5, + baseStats={hp=40,attack=40,defense=40,speed=40,special=40}, + }) +end]] +) +game.save.party[1].species = "SPECIES_25" +game.save.party[2] = { species = "CUSTOMMON", level = 1, stats = {}, moves = {} } +T.eq(api.world:party()[1].species, "PIKACHU", "patched alias keeps canonical pre-merge ID") +T.eq(api.world:party()[2].species, "CUSTOMMON", "late mod registration uses its authored ID") +local emptyParty = game.save.party +game.save.party = {} +T.same(api.world:party(), {}, "empty saved party returns an empty snapshot") +game.save.party = emptyParty +game.save.party[1].item = 0 +game.save.party[1].status = nil +game.save.party[1].isEgg = true +T.eq(api.world:party()[1].item, nil, "no-item native sentinel is absent") +T.eq(api.world:party()[1].status, nil, "healthy status is absent") +T.eq(api.world:party()[1].isEgg, true, "egg flag exposed") +-- Optional integration gate against #92's actual event record encoder. +if pcall(require, "src.mods.UnifiedCallbacks") then + local Unified = require("src.mods.UnifiedCallbacks") + Unified.install(loader, api, "probe", 4) + local eventMon + api.events:on("pokemon.received", function(event) + eventMon = event.mon + end) + loader.events:emit("pokemon.received", { mon = game.save.party[1] }) + T.same(api.world:party()[1], eventMon, "party/event live record shapes match exactly") +end + +-- Verify both normal RNG streams independently, without relying only on the +-- tripwire above. Lua's sequence must remain unchanged by the real readers. +math.randomseed(24680) +local expected = { math.random(), math.random(), math.random() } +math.randomseed(24680) +api.world:party() +api.world:activeBlockAt("ACTIVE", 1, 0) +api.world:liveMaps() +T.same({ math.random(), math.random(), math.random() }, expected, "reader calls leave RNG sequence unchanged") +for _, name in ipairs({ "party", "activeBlockAt", "liveMaps" }) do + T.check(Reads.supports(name), "new capability " .. name) +end +T.check(not Reads.supports("flyTo") and not Reads.supports({}), "reader capability list stays narrow") + +GameVersion.set(savedVersion) +Runtime.install(savedEvents, savedHooks, savedErrors) +love = savedLove +T.finish() From 9b7bc090401af8e80f89071e296bd215ae36db3e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:43:36 +0200 Subject: [PATCH 05/71] Add guarded public world actions and native flight --- docs/modding.md | 9 + docs/world-actions-api.md | 135 +++++++ src/mods/Loader.lua | 10 + src/world/WorldAPI.lua | 9 + src/world/WorldActions.lua | 469 +++++++++++++++++++++++ tests/engine/world_actions_api.lua | 582 +++++++++++++++++++++++++++++ 6 files changed, 1214 insertions(+) create mode 100644 docs/world-actions-api.md create mode 100644 src/world/WorldActions.lua create mode 100644 tests/engine/world_actions_api.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..33b9ee421 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -255,6 +255,15 @@ without the engine knowing the layout. Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. +## Public world actions + +`mod.world` exposes guarded party swaps, verified contextual field actions +and native flight through `canReorderParty`, `reorderParty`, +`availableFieldActions`, `useFieldAction`, `canFly` and `flyTo`. +See [contracts, busy-state checks and generation coverage](world-actions-api.md). +Calls revalidate the live state and preserve native gameplay paths; the +field-action adapter explicitly refuses unsupported script-driven cases. + ## Support link (`support`) A mod can add a link where players can thank its creator: a Ko-fi, Patreon or diff --git a/docs/world-actions-api.md b/docs/world-actions-api.md new file mode 100644 index 000000000..230b7737e --- /dev/null +++ b/docs/world-actions-api.md @@ -0,0 +1,135 @@ +# Public world actions + +`mod.world` adds six methods without a manifest opt-in or Mod API bump: +`canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, +`canFly`, and `flyTo`. The new actions validate free-roam state before using +existing native gameplay entry points. Existing world methods, party menus +and script commands keep their contracts. + +```lua +local ok, reason = mod.world:reorderParty(1, 2) +if not ok then mod.log:info("%s", reason) end + +local actions = mod.world:availableFieldActions() +for _, action in ipairs(actions) do + -- { id = "surf", label = "SURF" }, for example. + -- Show a companion UI; revalidate when the player actually chooses it. +end + +-- Later, from an explicit player choice: +local started, why = mod.world:useFieldAction("surf") +local flying, flyWhy = mod.world:flyTo("A_NATIVE_DESTINATION_MAP_ID") +``` + +## Free-roam boundary + +The save, overworld map, player, native script runner and authoritative stack +top must be present and ready. Methods refuse while another screen owns the +stack (including battles/dialogue), a script is running/queued, scripted +movement is pending, a transition or field animation is active, the player +is moving/locked, a mod script lock is held, a decoration mode owns input, +or a link session is live. Native engine errors propagate; an action that +throws is never retried by this facade. + +`canReorderParty()` and `canFly()` return booleans. Mutations return `true` +when accepted, or `nil, reason` when refused. `availableFieldActions()` gives +an array of detached `{ id, label }` records, or an empty array and a reason +when the world is unavailable/busy. Queries use native eligibility checks, +including existing mod hooks; the facade does not itself perform an action +while probing. A mod's own hook can still have ordinary native side effects. +Recursive mutations from those eligibility hooks are refused, and a changed +source context invalidates the pending request. + +A previous successful probe/list is not a reservation. Every action checks +state again when invoked. Typical reasons are `no save`, `no overworld`, +`world is busy`, invalid arguments, unavailable destination or unavailable +field action. Availability of the interface does not promise every action +exists in every generation. + +## Party ordering + +`reorderParty(fromSlot, toSlot)` swaps two existing party references, matching +the native party menus. It does not insert/remove Pokémon, recalculate them +or copy their data. Slots are integer, one-based indices from 1 to 6; both +must exist. Fainted Pokémon and eggs can be reordered. A same-slot request is +a successful no-op. Invalid slots refuse before changing the party. + +`canReorderParty()` is true only when the world is ready and there are at +least two party members. A retained facade reads the current save when used. +No menu selection or battle party order is changed behind a screen's back. + +## Flight + +`canFly()` additionally requires a real non-egg party carrier, the native +badge/location checks and at least one currently unlocked, valid destination. +`flyTo(mapId)` accepts a native map ID from that game's destination set. It +is not a general warp: unknown, locked, unvisited or invalid landing points +are refused before departure state changes. A caller-provided coordinate, +spawn-table index or arbitrary destination object is not accepted. + +| Host | Eligibility and destinations | Native departure | +| --- | --- | --- | +| Gen 1/2 | `partyKnows("FLY")`, outside-map rules, visited `field.flyOrder` towns with `flyWarps` | `OverworldState:flyTo` | +| Gen 3 | Native carrier/badge check, `Gen3FieldRules`, visited town set | Native field-move cut-in, then `flyTo` | +| Platinum / HGSS | `Gen4FieldMoves`, `Gen4Fly.check/destinations`, partner/Safari/badge checks and HGSS costume refusal | `gen4FlyTo` | +| Black / White | Native numeric move lookup, `Gen5Fly.allowed/destinations/destination`; no invented badge gate | `gen4FlyTo` | + +The native handlers retain departure timing, player input lock, bike/surf +state changes and exact landing coordinates. The direct map-ID call does +not open a destination picker. Gen 3 keeps its native Pokémon cut-in, falling +back directly when there is no picture to display. + +## Verified contextual field actions + +This initial adapter deliberately exposes only these native paths: + +| Action ID | Gen 1 | Gen 2 | Gen 3 | Gen 4/5 | +| --- | --- | --- | --- | --- | +| `cut` | Native cut eligibility + `tryCut` | Same, reaching native Gen 2 Cut | Refused: object-script adapter required | Refused: native script adapter required | +| `surf` | Native eligibility + `trySurf` | Same | Same, with native field-move presentation | Refused: native script adapter required | +| `dig` | Eligible dungeon → native last-heal departure | Eligible cave/dungeon → recorded entrance | `Gen3FieldRules` → recorded entrance | Refused: native script adapter required | +| `teleport` | Outside map → last heal | Outside map → last heal | Native travel rules → last heal | Refused: native script adapter required | + +Native carrier lookup selects a current non-egg party member. Badge checks +and target/location gates are retained; fainted carriers remain eligible +where the native engine permits them. Surf starts mounting; there is no +invented generic dismount action. Dig and Teleport keep distinct native +escape/last-heal semantics. Cut uses live target eligibility rather than +matching a global block number. + +`useFieldAction(id, opts)` accepts no options or an empty plain options table. +Unsupported options, action IDs and generation adapters return a reason. +This version does not expose bicycle/fishing, Strength/Flash, healing moves, +custom source selection, Gen 3 object-script actions or DS field-script +activation. These require separate adapters with their native checks; callers +must use the returned action list rather than assuming a fixed menu. Fly is +separate because it needs a destination. + +## Deferred completion and cancellation + +A `true` result means the native request was accepted, not that the animation, +dialogue or warp has finished. Native gameplay owns those asynchronous flows. +A Gen 3 cut-in blocks competing public mutations until it finishes. Its +effect is guarded by the source save/data/map/player, position/facing, version, +party membership and fresh eligibility. Changing that context cancels the +effect. A repeated completion callback cannot replay it. Abandoning the +presentation does not leave a permanent facade lock; a stale callback cannot +start a later effect. Already executed native gameplay is not rolled back. + +## Capabilities and tests + +With the optional capability-query feature, each of these six method names +returns true from `mod:supports("world", name)` without constructing Game or +the world. Other capability answers are delegated unchanged. Use +`canFly`/`availableFieldActions` and the mutation's return value for current +state and action-level availability. + +`tests/engine/world_actions_api.lua` uses the real loader across all 15 host +version IDs. It exercises refusal parity for world/player/script/link locks, +party identities, slots, destination gates, eggs, partner/costume checks, +contextual dispatch and reentrant/deferred/error paths. It also runs actual +native carrier/badge lookup, Fly/DS Fly departure methods and Gen 1–3 +Dig/Teleport departure state changes without ROM assets or graphics. A cut-in +test double drives completion/cancellation deterministically; it is not a +rendered playthrough. ROM-dependent map scripts and graphics still need an +imported legal ROM. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..51fa8a5bf 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -23,6 +23,7 @@ local ModStorage = require("src.mods.Storage") local Runtime = require("src.mods.Runtime") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") +local WorldActions = require("src.world.WorldActions") -- Module-name aliases for mods written against the Gold port's per-generation -- layout (src.world.gen2.Player and friends). Installed here because this is -- the module that loads mods: the searcher has to be in place before the @@ -1124,6 +1125,15 @@ function Loader:_api(mod) -- the Game the facade acts on is still being wired when the entry chunk -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. + -- When capability queries are installed, these additive methods exist on + -- every host; individual actions still validate native version and state. + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and WorldActions.supports(name) then return true end + return supports(self, surface, name) + end + end local world, storage setmetatable(api, { __index = function(_, key) if key == "game" then return loader:_game() end diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index f21419ae9..3b94e0c15 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -6,6 +6,7 @@ -- stays unsupported; anything a mod legitimately needs belongs here. local Logger = require("src.core.Logger") +local Actions = require("src.world.WorldActions") local MapLoader = require("src.world.MapLoader") local Gen3Elevation = require("src.world.Gen3Elevation") local Runtime = require("src.mods.Runtime") @@ -386,6 +387,14 @@ function WorldAPI:queueScript(rows, extra) return true end +-- Public field mutations are kept behind the same free-roam boundary. +function WorldAPI:canReorderParty() return Actions.canReorderParty(self.game, self:overworld()) end +function WorldAPI:reorderParty(fromSlot, toSlot) return Actions.reorderParty(self.game, self:overworld(), fromSlot, toSlot) end +function WorldAPI:availableFieldActions() return Actions.availableFieldActions(self.game, self:overworld()) end +function WorldAPI:useFieldAction(id, opts) return Actions.useFieldAction(self.game, self:overworld(), id, opts) end +function WorldAPI:canFly() return Actions.canFly(self.game, self:overworld()) end +function WorldAPI:flyTo(mapId) return Actions.flyTo(self.game, self:overworld(), mapId) end + -- drop a map's cached instance so the next load re-reads its record; when -- it is the active map the world reloads around the player in place function WorldAPI:invalidateMap(mapId) diff --git a/src/world/WorldActions.lua b/src/world/WorldActions.lua new file mode 100644 index 000000000..d6172f82c --- /dev/null +++ b/src/world/WorldActions.lua @@ -0,0 +1,469 @@ +-- Supported mutations go through the existing gameplay entry points. Shared +-- guards keep companion UIs out of screens/scripts/transitions that own input. +local GameVersion = require("src.core.GameVersion") +local Map = require("src.world.Map") +local FieldDefaults = require("src.world.FieldDefaults") +local Party = require("src.pokemon.Party") +local A = {} +local METHODS = { + canReorderParty = true, + reorderParty = true, + availableFieldActions = true, + useFieldAction = true, + canFly = true, + flyTo = true, +} +local LOCKS = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "frameScriptThisTick", + "_modScriptLock", +} +local pending = setmetatable({}, { __mode = "k" }) +local planning = setmetatable({}, { __mode = "k" }) +local function integer(v, lo, hi) + return type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi +end +function A.supports(name) + return type(name) == "string" and METHODS[name] == true +end + +local function ready(game, ow) + if not (game and game.save) then + return nil, "no save" + end + if not (ow and ow.map and ow.player) then + return nil, "no overworld" + end + local token = pending[game] + if token and token.state then + local held = false + for _, state in ipairs(game.stack and game.stack.states or {}) do + if state == token.state then + held = true + end + end + if game.stack and game.stack.top and game.stack:top() == token.state then + held = true + end + if + not held + or game.save ~= token.save + or game.data ~= token.data + or ow.map ~= token.map + or ow.player ~= token.player + then + pending[game] = nil + end + end + if not (game.stack and game.stack.top and game.stack:top() == ow) then + return nil, "world is busy" + end + if + pending[game] + or planning[game] + or (game.overworld and game.overworld ~= ow) + or game.linkSession + or (game.linkNet and not game.linkNet.closed) + then + return nil, "world is busy" + end + for _, key in ipairs(LOCKS) do + if ow[key] then + return nil, "world is busy" + end + end + local player = ow.player + if player.moving or player.inputLocked or player.stairExit or player.spinning or player.fieldMove then + return nil, "world is busy" + end + if not (ow.runner and type(ow.runner.isRunning) == "function") then + return nil, "world is busy" + end + if ow.runner:isRunning() or #(ow.scriptMoves or {}) > 0 or #(ow.pendingScripts or {}) > 0 then + return nil, "world is busy" + end + return true +end +local function guardedCall(game, fn, ...) + planning[game] = true + local result = { pcall(fn, game, ...) } + planning[game] = nil + if not result[1] then + error(result[2], 0) + end + return result[2], result[3] +end +local function party(game) + return game and game.save and game.save.party or {} +end +local function member(game, mon) + if type(mon) ~= "table" or Party.isEgg(mon) then + return nil + end + for _, p in ipairs(party(game)) do + if p == mon then + return mon + end + end +end +local function user(game, ow, move) + if type(ow.partyKnows) ~= "function" then + return nil + end + return member(game, ow:partyKnows(move)) +end +local function context(game, ow) + local p = ow.player + return { + save = game.save, + data = game.data, + map = ow.map, + player = p, + x = p.cellX, + y = p.cellY, + facing = p.facing, + version = GameVersion.get(), + } +end +local function same(game, ow, c) + return game.save == c.save + and game.data == c.data + and ow.map == c.map + and ow.player == c.player + and ow.player.cellX == c.x + and ow.player.cellY == c.y + and ow.player.facing == c.facing + and GameVersion.get() == c.version +end +local function currentWorld(game, ow) + -- A state replaced while a cut-in was active must not receive its effect. + return game.stack and game.stack.top and game.stack:top() == ow +end +local function present(game, ow, mon, check, run) + local c = context(game, ow) + local called = false + local token = { save = game.save, data = game.data, map = ow.map, player = ow.player } + pending[game] = token + local result, reason + local function finish() + if called then + return + end + called = true + if pending[game] ~= token then + return + end + pending[game] = nil + if + not currentWorld(game, ow) + or not same(game, ow, c) + or not ready(game, ow) + or not member(game, mon) + then + result, reason = nil, "world changed" + return + end + if not check() then + result, reason = nil, "action unavailable" + return + end + -- A capability hook invoked by check() may have changed the session. + if not same(game, ow, c) or not ready(game, ow) then + result, reason = nil, "world changed" + return + end + local answer = guardedCall(game, run) + if answer == false then + result, reason = nil, "action unavailable" + else + result = true + end + end + if GameVersion.generation() == 3 then + -- Never retry an engine callback that threw after invoking finish(). + local ok, shown = pcall(function() + return require("src.world.Gen3FieldMove").show(game, mon, finish) + end) + if not ok then + pending[game] = nil + error(shown, 0) + end + if shown then + token.state = game.stack:top() + return true + end + end + finish() + return result, reason +end + +function A.canReorderParty(game, ow) + return #party(game) > 1 and ready(game, ow) == true +end +function A.reorderParty(game, ow, from, to) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + local p = party(game) + if + not integer(from, 1, Party.MAX) + or not integer(to, 1, Party.MAX) + or type(p[from]) ~= "table" + or type(p[to]) ~= "table" + then + return nil, "invalid party slot" + end + -- Match the native party menus: swap references, never reconstruct mons. + if from ~= to then + p[from], p[to] = p[to], p[from] + end + return true +end + +local function outside(game, ow) + return Map.isOutside(ow.map.def, FieldDefaults.field(game.data, "outsideTilesets")) +end +local function validSpot(data, id, x, y, gen) + local def = data and data.maps and data.maps[id] + if not def or not integer(def.width, 1, 1000000) or not integer(def.height, 1, 1000000) then + return false + end + local tileset = data.tilesets and data.tilesets[def.tileset] + local cells = tileset and tileset.blockCells or (gen >= 3 and 1 or 2) + return integer(cells, 1, 1000000) + and integer(x, 0, def.width * cells - 1) + and integer(y, 0, def.height * cells - 1) +end +local function flyPlan(game, ow, mapId) + local gen = GameVersion.generation() + local data, save = game.data or {}, game.save + local mon + if gen >= 4 then + local F = require("src.world.Gen4FieldMoves") + for _, p in ipairs(party(game)) do + if not Party.isEgg(p) and F.knows(p, "FLY") then + mon = p + break + end + end + if not mon then + return nil, "fly unavailable" + end + if gen == 5 then + local Fly = require("src.world.Gen5Fly") + if not Fly.allowed(ow.map.def) or type(ow.gen4FlyTo) ~= "function" then + return nil, "fly unavailable" + end + for _, loc in ipairs(Fly.destinations(data, save)) do + local dest = Fly.destination(data, loc) + if dest and (not mapId or dest.map == mapId) and validSpot(data, dest.map, dest.x, dest.y, gen) then + return { mon = mon, dest = dest, ds = true } + end + end + else + local Fly = require("src.world.Gen4Fly") + local costume = require("src.inventory.Gen4FieldChecks").inCostume(data, save) + if costume or not Fly.check(data, save, ow.map.def) or type(ow.gen4FlyTo) ~= "function" then + return nil, "fly unavailable" + end + for _, dest in ipairs(Fly.destinations(data, save)) do + if (not mapId or dest.map == mapId) and validSpot(data, dest.map, dest.x, dest.y, gen) then + return { mon = mon, dest = { map = dest.map, x = dest.x, y = dest.y }, ds = true } + end + end + end + else + mon = user(game, ow, "FLY") + local travel = gen == 3 and require("src.world.Gen3FieldRules").allowsTravel(ow.map.def) + or (gen < 3 and outside(game, ow)) + if not mon or not travel or type(ow.flyTo) ~= "function" then + return nil, "fly unavailable" + end + local field = data.field or {} + for _, id in ipairs(field.flyOrder or {}) do + local def = data.maps and data.maps[id] + local spot = field.flyWarps and field.flyWarps[id] + if + (not mapId or mapId == id) + and save.visited + and save.visited[id] + and spot + and def + and (Map.isOutdoor(def) or def.tileset == "PLATEAU") + and validSpot(data, id, spot.x, spot.y, gen) + then + return { mon = mon, dest = { map = id, x = spot.x, y = spot.y } } + end + end + end + return nil, "destination unavailable" +end +function A.canFly(game, ow) + if not ready(game, ow) then + return false + end + local c = context(game, ow) + return guardedCall(game, flyPlan, ow) ~= nil and same(game, ow, c) and ready(game, ow) == true +end +function A.flyTo(game, ow, mapId) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + if type(mapId) ~= "string" or mapId == "" then + return nil, "invalid destination" + end + local c = context(game, ow) + local plan, why = guardedCall(game, flyPlan, ow, mapId) + if not plan then + return nil, why + end + if not same(game, ow, c) or not ready(game, ow) then + return nil, "world changed" + end + return present(game, ow, plan.mon, function() + local fresh = guardedCall(game, flyPlan, ow, mapId) + return fresh and fresh.mon == plan.mon and fresh.dest.x == plan.dest.x and fresh.dest.y == plan.dest.y + end, function() + if plan.ds then + return ow:gen4FlyTo(plan.dest, plan.mon) + end + return ow:flyTo(plan.dest.map, plan.mon) + end) +end + +local ORDER = { "cut", "surf", "dig", "teleport" } +local function fieldPlan(game, ow, id) + local gen = GameVersion.generation() + if gen >= 4 then + return nil, "DS field actions require a native script adapter" + end + if id == "cut" then + if gen == 3 then + return nil, "Gen 3 Cut requires an object-script adapter" + end + local mon = user(game, ow, "CUT") + if + mon + and type(ow.useCutFieldMove) == "function" + and ow:useCutFieldMove() == "ok" + and type(ow.tryCut) == "function" + then + return { + mon = mon, + run = function() + local x, y = ow.player:facingCell() + return ow:tryCut(x, y) + end, + } + end + elseif id == "surf" then + local mon = user(game, ow, "SURF") + if + mon + and not ow.player.surfing + and type(ow.useSurfFieldMove) == "function" + and ow:useSurfFieldMove() == "ok" + and type(ow.trySurf) == "function" + then + return { + mon = mon, + run = function() + local x, y = ow.player:facingCell() + return ow:trySurf(x, y, nil, mon) + end, + } + end + elseif id == "dig" or id == "teleport" then + local mon = user(game, ow, id:upper()) + if not mon or type(ow.beginTeleportOut) ~= "function" then + return nil, "field action unavailable" + end + local escape = false + if id == "teleport" then + local travel = gen == 3 and require("src.world.Gen3FieldRules").allowsTravel(ow.map.def) + or (gen < 3 and outside(game, ow)) + if not travel or not game.save.lastHeal then + return nil, "field action unavailable" + end + elseif gen == 1 then + local tiles = { FOREST = true, CEMETERY = true, CAVERN = true, FACILITY = true, INTERIOR = true } + if not tiles[ow.map.def.tileset] or ow.map.id == "AGATHAS_ROOM" or not game.save.lastHeal then + return nil, "field action unavailable" + end + else + local point = type(ow.escapePoint) == "function" and ow:escapePoint() + if not point then + return nil, "field action unavailable" + end + if gen == 3 then + if not require("src.world.Gen3FieldRules").allowsEscape(ow.map.def, point) then + return nil, "field action unavailable" + end + elseif ow.map.def.environment ~= 4 and ow.map.def.environment ~= 7 then + return nil, "field action unavailable" + end + escape = true + end + return { + mon = mon, + run = function() + return ow:beginTeleportOut(nil, { escape = escape or nil }) + end, + } + end + return nil, "field action unavailable" +end +function A.availableFieldActions(game, ow) + local ok, reason = ready(game, ow) + if not ok then + return {}, reason + end + local c = context(game, ow) + local out = {} + for _, id in ipairs(ORDER) do + if guardedCall(game, fieldPlan, ow, id) then + out[#out + 1] = { id = id, label = id:upper() } + end + if not same(game, ow, c) or not ready(game, ow) then + return {}, "world changed" + end + end + return out +end +function A.useFieldAction(game, ow, id, opts) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + if type(id) ~= "string" then + return nil, "invalid field action" + end + if opts ~= nil and (type(opts) ~= "table" or next(opts) ~= nil or getmetatable(opts) ~= nil) then + return nil, "unsupported field action options" + end + local c = context(game, ow) + local plan, why = guardedCall(game, fieldPlan, ow, id) + if not plan then + return nil, why + end + if not same(game, ow, c) or not ready(game, ow) then + return nil, "world changed" + end + return present(game, ow, plan.mon, function() + local fresh = guardedCall(game, fieldPlan, ow, id) + return fresh and fresh.mon == plan.mon + end, plan.run) +end +return A diff --git a/tests/engine/world_actions_api.lua b/tests/engine/world_actions_api.lua new file mode 100644 index 000000000..bd029b204 --- /dev/null +++ b/tests/engine/world_actions_api.lua @@ -0,0 +1,582 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local WorldAPI = require("src.world.WorldAPI") +local Actions = require("src.world.WorldActions") +local GameVersion = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Merge = require("src.mods.Merge") +local Json = require("src.link.Json") +local G4Fly = require("src.world.Gen4Fly") +local Gen3Show = require("src.world.Gen3FieldMove") +local saved = { GameVersion.get(), Runtime.events, Runtime.hooks, Runtime.errors, love, Gen3Show.show } +love = nil +-- Deterministic no-picture native fallback for the cross-version cases. +Gen3Show.show = function() + return false +end +local function boot(version) + GameVersion.set(version) + local gen = GameVersion.generation() + local data = { + pokemon = {}, + moves = {}, + items = {}, + constants = { badges = {} }, + tilesets = { TEST = { blockCells = gen < 3 and 2 or 1 } }, + maps = { + HOME = { + id = "HOME", + width = 20, + height = 20, + tileset = "TEST", + outdoor = true, + mapType = "ROUTE", + allowFly = true, + gen5Flags = 0x2000, + }, + DEST = { id = "DEST", width = 20, height = 20, tileset = "TEST", outdoor = true, mapType = "CITY" }, + }, + field = { flyOrder = { "DEST" }, flyWarps = { DEST = { x = 3, y = 4 } } }, + } + for i = 1, 16 do + data.constants.badges[i] = { id = "BADGE_" .. i } + end + local save = { + party = { + { species = "A", hp = 1, moves = { { id = gen >= 4 and 19 or "FLY" } } }, + { + species = "B", + hp = 0, + moves = {}, + }, + }, + visited = { DEST = true }, + lastHeal = { map = "DEST", x = 3, y = 4 }, + flags = {}, + badges = {}, + } + for i = 1, 16 do + save.badges["BADGE_" .. i] = true + end + if gen == 4 then + data.gen4_version = + { family = (version == "heartgold" or version == "soulsilver") and "hgss" or "platinum" } + local row = { index = 0, firstArrival = 1, fly = { map = "DEST", x = 3, y = 4 } } + data.field.healLocations = { row } + save.flags[G4Fly.rowFlagKey(data, row)] = true + elseif gen == 5 then + data.maps.Z001 = data.maps.DEST + data.gen5_town_map = + { locations = { { zone = 1, flyPoint = true, spawn = { main = true, x = 3, z = 4 } } } } + save.gen5FlyZones = { ["1"] = true } + end + local calls = {} + local ow = { + isOverworld = true, + map = { id = "HOME", def = data.maps.HOME }, + player = { cellX = 2, cellY = 3, facing = "up" }, + scriptMoves = {}, + pendingScripts = {}, + runner = { + isRunning = function() + return false + end, + }, + } + ow.player.facingCell = function(p) + return p.cellX, p.cellY - 1 + end + local game + ow.partyKnows = function(_, move) + if move == "FLY" then + return game.save.party[1] + end + end + ow.flyTo = function(_, id, mon) + calls[#calls + 1] = { kind = "fly", id = id, mon = mon } + ow.flyAnim = {} + end + ow.gen4FlyTo = function(_, dest, mon) + calls[#calls + 1] = { kind = "fly", id = dest.map, x = dest.x, y = dest.y, mon = mon } + ow.flyAnim = {} + end + local stack = { states = { ow } } + function stack:top() + return self.states[#self.states] + end + function stack:push(state) + self.states[#self.states + 1] = state + end + function stack:pop() + return table.remove(self.states) + end + game = { data = data, save = save, overworld = ow, stack = stack } + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolved = 0 + loader._game = function() + resolved = resolved + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return loader.apis.probe, game, ow, calls, function() + return resolved + end +end +for _, version in ipairs(GameVersion.ORDER) do + local api, game, ow, calls, resolved = boot(version) + local label = version .. ": " + T.eq(resolved(), 0, label .. "building API does not construct Game") + if api.supports then + for _, name in ipairs({ + "canReorderParty", + "reorderParty", + "availableFieldActions", + "useFieldAction", + "canFly", + "flyTo", + }) do + T.eq(api:supports("world", name), true, label .. "capability " .. name) + end + T.eq(resolved(), 0, label .. "capability probes do not construct Game") + end + local world = api.world + local before = Merge.deepCopy(game) + T.eq(world:canReorderParty(), true, label .. "free-roam party can reorder") + T.eq(world:canFly(), true, label .. "native flight checks pass") + T.same(game, before, label .. "availability does not alter save/world") + local p = game.save.party + local a, b = p[1], p[2] + T.eq(world:reorderParty(1, 2), true, label .. "swap accepted") + T.eq(game.save.party, p, label .. "party list identity retained") + T.eq(p[1], b, label .. "fainted party member may move") + T.eq(p[2], a, label .. "Pokemon identity retained") + T.eq(world:reorderParty(1, 2), true, label .. "swap back accepted") + T.eq(world:reorderParty(1, 1), true, label .. "same-slot no-op accepted") + for _, slot in ipairs({ 0, -1, 3, 7, 1.5, "1", math.huge, 0 / 0 }) do + before = Merge.deepCopy(game) + local ok, reason = world:reorderParty(slot, 1) + T.check(ok == nil and reason == "invalid party slot", label .. "invalid slot refused") + T.same(game, before, label .. "bad swap atomic") + end + local locks = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "frameScriptThisTick", + "_modScriptLock", + } + for _, lock in ipairs(locks) do + ow[lock] = true + before = Merge.deepCopy(game) + T.eq(world:canReorderParty(), false, label .. lock .. " blocks reorder probe") + T.eq(world:canFly(), false, label .. lock .. " blocks fly probe") + T.eq(world:reorderParty(1, 2), nil, label .. lock .. " blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. lock .. " blocks flight") + T.eq(world:useFieldAction("surf"), nil, label .. lock .. " blocks field use") + T.same(world:availableFieldActions(), {}, label .. lock .. " suppresses actions") + T.same(game, before, label .. lock .. " refusal leaves game unchanged") + ow[lock] = nil + end + for _, lock in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + ow.player[lock] = true + before = Merge.deepCopy(game) + T.eq(world:reorderParty(1, 2), nil, label .. "player " .. lock .. " blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. "player " .. lock .. " blocks fly") + T.same(game, before, label .. "player " .. lock .. " no mutation") + ow.player[lock] = nil + end + game.stack:push({ isBattle = true }) + T.eq(world:reorderParty(1, 2), nil, label .. "battle/screen above world blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. "battle/screen blocks fly") + game.stack:pop() + ow.runner.isRunning = function() + return true + end + T.eq(world:reorderParty(1, 2), nil, label .. "running VM blocks swap") + ow.runner.isRunning = function() + return false + end + ow.pendingScripts[1] = {} + T.eq(world:flyTo("DEST"), nil, label .. "pending scene blocks flight") + ow.pendingScripts = {} + ow.scriptMoves[1] = {} + T.eq(world:reorderParty(1, 2), nil, label .. "scripted movement blocks swap") + ow.scriptMoves = {} + game.linkSession = {} + T.eq(world:flyTo("DEST"), nil, label .. "link lock blocks flight") + game.linkSession = nil + game.linkNet = { closed = false } + T.eq(world:reorderParty(1, 2), nil, label .. "live link net blocks swap") + game.linkNet = nil + local destination = GameVersion.generation() == 5 and "Z001" or "DEST" + before = Merge.deepCopy(game) + T.eq(world:flyTo("MISSING"), nil, label .. "unknown destination refused") + T.eq(world:flyTo({}), nil, label .. "non-string destination refused") + T.same(game, before, label .. "invalid destinations leave save/world unchanged") + T.eq(#calls, 0, label .. "no gameplay handlers ran on refusal") + T.eq(world:flyTo(destination), true, label .. "flight accepted") + T.eq(#calls, 1, label .. "one native departure") + T.eq(calls[1].id, destination, label .. "native destination selected") + T.eq(calls[1].mon, a, label .. "native carrier identity retained") + T.eq(world:flyTo(destination), nil, label .. "in-flight repeat refused") + T.eq(#calls, 1, label .. "repeat does not replay departure") + ow.flyAnim = nil + a.isEgg = true + T.eq(world:canFly(), false, label .. "egg cannot carry flight") + a.isEgg = nil + if GameVersion.generation() == 4 then + game.save.flags.FLAG_G4_0961 = true + T.eq(world:canFly(), false, label .. "native partner restriction retained") + game.save.flags.FLAG_G4_0961 = nil + if version == "heartgold" or version == "soulsilver" then + game.save.hgssRocketCostume = true + T.eq(world:canFly(), false, label .. "Rocket costume restriction retained") + game.save.hgssRocketCostume = nil + end + end + game.save = nil + T.eq(world:canReorderParty(), false, label .. "no save probe safe") + T.eq(world:reorderParty(1, 2), nil, label .. "no save swap safe") + T.eq(world:canFly(), false, label .. "no save fly probe safe") + T.eq(world:flyTo(destination), nil, label .. "no save fly safe") +end + +-- Exercise the actual native carrier/badge lookup and departure methods, +-- including their input lock and animation/landing state, without graphics. +local OW = require("src.world.OverworldController") +love = {} -- native sound entry points expect a host table, but need no audio device +local gameUpvalue, oldGame +for i = 1, 30 do + local name, value = debug.getupvalue(OW.flyTo, i) + if name == "Game" then + gameUpvalue = i + oldGame = value + break + end +end +assert(gameUpvalue, "native Game binding") +for _, version in ipairs(GameVersion.ORDER) do + local api, game, ow = boot(version) + debug.setupvalue(OW.flyTo, gameUpvalue, game) + ow.partyKnows = OW.partyKnows + ow.flyTo = OW.flyTo + ow.gen4FlyTo = OW.gen4FlyTo + ow.closeToMap = OW.closeToMap + ow.clearBikeFlags = function() end + ow.hasFlyBird = function() + return false + end + ow.syncSurfingPikachu = function() end + local gen = GameVersion.generation() + if gen <= 2 then + game.data.constants.hmBadges = { FLY = { badge = "FLY_ALLOWED" } } + game.save.flags.FLY_ALLOWED = true + elseif gen == 3 then + game.data.constants.gen3FieldMoves = { FLY = { badgeFlag = 0x820 } } + game.save.flags.FLAG_G3_0820 = true + end + local destination = gen == 5 and "Z001" or "DEST" + T.eq(api.world:canFly(), true, version .. " actual native eligibility") + local before = Merge.deepCopy(game) + if gen <= 2 then + game.save.flags.FLY_ALLOWED = nil + elseif gen == 3 then + game.save.flags.FLAG_G3_0820 = nil + elseif gen == 4 then + for k in pairs(game.save.badges) do + game.save.badges[k] = nil + end + end + if gen < 5 then + T.eq(api.world:canFly(), false, version .. " native missing badge refuses flight") + T.eq(api.world:flyTo(destination), nil, version .. " missing badge has no departure") + T.eq(ow.flyAnim, nil, version .. " denied departure has no animation") + end + game.save = Merge.deepCopy(before.save) + if gen <= 3 then + game.save.visited.DEST = nil + elseif gen == 4 then + game.save.flags = {} + else + game.save.gen5FlyZones = {} + end + T.eq(api.world:canFly(), false, version .. " unvisited/locked destinations unavailable") + T.eq(api.world:flyTo(destination), nil, version .. " locked destination refuses native handler") + game.save = Merge.deepCopy(before.save) + -- Validate the landing before any departure-side state is changed. + if gen <= 3 then + game.data.field.flyWarps.DEST.x = -1 + elseif gen == 4 then + game.data.field.healLocations[1].fly.x = -1 + else + game.data.gen5_town_map.locations[1].spawn.x = -1 + end + T.eq(api.world:flyTo(destination), nil, version .. " invalid landing refused") + T.eq(ow.flyAnim, nil, version .. " invalid landing leaves animation absent") + if gen <= 3 then + game.data.field.flyWarps.DEST.x = 3 + elseif gen == 4 then + game.data.field.healLocations[1].fly.x = 3 + else + game.data.gen5_town_map.locations[1].spawn.x = 3 + end + game.save.onBike = true + ow.player.surfing = true + T.eq(api.world:flyTo(destination), true, version .. " native departure accepted") + T.eq(ow.flyAnim.frames, 48, version .. " native flight timing preserved") + T.eq(ow.flyDest.map, destination, version .. " native landing map preserved") + T.eq(ow.flyDest.x, 3, version .. " native landing x preserved") + T.eq(ow.flyDest.y, 4, version .. " native landing y preserved") + T.eq(ow.player.inputLocked, true, version .. " native input lock applied") + T.eq(ow.player.surfing, false, version .. " native surfing cleared") + T.eq(game.save.onBike, false, version .. " native bicycle state cleared") +end +for _, version in ipairs({ "red", "gold", "emerald" }) do + local api, game, ow = boot(version) + debug.setupvalue(OW.flyTo, gameUpvalue, game) + local gen = GameVersion.generation() + ow.partyKnows = OW.partyKnows + ow.beginTeleportOut = OW.beginTeleportOut + ow.syncSurfingPikachu = function() end + ow.escapePoint = function() + return { map = "DEST", x = 3, y = 4 } + end + game.save.party[1].moves = { { id = "DIG" }, { id = "TELEPORT" } } + ow.map.def.tileset = "CAVERN" + ow.map.def.environment = 4 + ow.map.def.allowEscaping = true + T.eq(api.world:useFieldAction("dig"), true, version .. " actual native Dig starts") + T.eq(ow.teleportOut.frames, 48, version .. " native Dig animation timing") + T.eq(ow.teleportOut.escape, gen >= 2 and true or nil, version .. " native Dig escape destination semantics") + T.eq(ow.player.inputLocked, true, version .. " native Dig input lock") + ow.teleportOut = nil + ow.player.inputLocked = false + ow.player.spinning = false + ow.map.def.tileset = "OVERWORLD" + ow.map.def.environment = 1 + T.eq(api.world:useFieldAction("teleport"), true, version .. " actual native Teleport starts") + T.eq(ow.teleportOut.frames, 48, version .. " native Teleport animation timing") + T.eq(ow.teleportOut.escape, nil, version .. " native Teleport uses last heal point") +end +debug.setupvalue(OW.flyTo, gameUpvalue, oldGame) +love = nil + +-- Field dispatch is tested separately from presentation, using native IDs +-- and the exact overworld methods called by the existing party menus. +for _, version in ipairs({ "red", "gold", "emerald", "platinum", "black" }) do + local api, game, ow, calls = boot(version) + local world = api.world + local gen = GameVersion.generation() + local mon = game.save.party[1] + ow.partyKnows = function() + return mon + end + ow.useSurfFieldMove = function() + return "ok" + end + ow.useCutFieldMove = function() + return "ok" + end + ow.trySurf = function(_, x, y, close, carrier) + calls[#calls + 1] = { kind = "surf", carrier = carrier, x = x, y = y } + game.stack:push({ isText = true }) + end + ow.tryCut = function(_, x, y) + calls[#calls + 1] = { kind = "cut", x = x, y = y } + game.stack:push({ isText = true }) + return true + end + ow.beginTeleportOut = function(_, done, opts) + calls[#calls + 1] = { kind = "escape", escape = opts.escape } + ow.teleportOut = {} + end + ow.escapePoint = function() + return { map = "DEST", x = 3, y = 4 } + end + local before = Merge.deepCopy(game) + local list = world:availableFieldActions() + T.same(game, before, version .. " field availability is data-only with native pure gates") + local names = {} + for _, row in ipairs(list) do + names[row.id] = true + end + T.eq(names.surf, gen <= 3 and true or nil, version .. " verified surf coverage") + T.eq(names.cut, gen <= 2 and true or nil, version .. " verified cut coverage") + T.eq(names.teleport, gen <= 3 and true or nil, version .. " verified teleport coverage") + if list[1] then + list[1].id = "MUTATED" + T.check(world:availableFieldActions()[1].id ~= "MUTATED", version .. " action rows detached") + end + T.eq(world:useFieldAction("unknown"), nil, version .. " unknown action refused") + T.eq(world:useFieldAction("surf", { sourceSlot = 1 }), nil, version .. " unsupported options refused") + if gen <= 3 then + T.eq(world:useFieldAction("surf"), true, version .. " native surf started") + T.eq(calls[#calls].carrier, mon, version .. " surf uses validated carrier") + T.eq(world:useFieldAction("surf"), nil, version .. " active dialogue prevents replay") + game.stack:pop() + ow.player.surfing = true + T.eq(world:useFieldAction("surf"), nil, version .. " no invented dismount action") + ow.player.surfing = false + T.eq(world:useFieldAction("teleport"), true, version .. " native Teleport started") + T.eq(calls[#calls].escape, nil, version .. " Teleport keeps heal-point semantics") + ow.teleportOut = nil + ow.map.def.tileset = "CAVERN" + ow.map.def.environment = 4 + ow.map.def.allowEscaping = true + T.eq(world:useFieldAction("dig"), true, version .. " native Dig started") + T.eq( + calls[#calls].escape, + gen >= 2 and true or nil, + version .. " Dig keeps generation-specific destination" + ) + ow.teleportOut = nil + if gen <= 2 then + T.eq(world:useFieldAction("cut"), true, version .. " native Cut started") + game.stack:pop() + end + else + T.eq(world:useFieldAction("surf"), nil, version .. " unsupported script adapter is explicit") + T.eq(#calls, 0, version .. " unsupported field dispatch has no effects") + end +end + +-- Deferred Gen 3 cut-ins own the request until completion; callbacks may be +-- invoked twice or outlive a replaced map/save, but effects must not replay. +local api, game, ow, calls = boot("emerald") +local world = api.world +local finish +Gen3Show.show = function(g, mon, done) + finish = done + g.stack:push({ isCutin = true }) + return true +end +T.eq(world:flyTo("DEST"), true, "cut-in queued") +T.eq(#calls, 0, "no departure before cut-in finishes") +T.eq(world:reorderParty(1, 2), nil, "cut-in blocks competing action") +game.stack:pop() +finish() +finish() +T.eq(#calls, 1, "cut-in completion departs once") +ow.flyAnim = nil +T.eq(world:flyTo("DEST"), true, "second independent request queued") +game.stack:pop() +game.save = Merge.deepCopy(game.save) +finish() +T.eq(#calls, 1, "save replacement cancels pending effect") +T.eq(world:canReorderParty(), true, "cancelled request releases its lock") +T.eq(world:flyTo("DEST"), true, "request can resume after cancellation") +game.stack:pop() +ow.map = { id = "OTHER", def = game.data.maps.HOME } +finish() +T.eq(#calls, 1, "map replacement cancels pending effect") +T.eq(world:canReorderParty(), true, "map cancellation releases lock") +T.eq(world:flyTo("DEST"), true, "abandoned presentation queued") +game.stack:pop() -- simulate native UI being dismissed without completion +T.eq(world:canReorderParty(), true, "abandoned cut-in does not leave a permanent API lock") +finish() +T.eq(#calls, 1, "abandoned callback cannot later run") +Gen3Show.show = function(g, mon, done) + done() + error("after completion") +end +local ok, err = pcall(function() + world:flyTo("DEST") +end) +T.check( + not ok and tostring(err):find("after completion", 1, true) ~= nil, + "native presentation error is propagated" +) +T.eq(#calls, 2, "presentation failure never retries a completed departure") +Gen3Show.show = function() + return false +end +ow.flyAnim = nil +-- Hooks cannot recursively start a second action or move the source unnoticed. +ow.partyKnows = function() + T.eq(world:reorderParty(1, 2), nil, "nested mutation during eligibility is refused") + return game.save.party[1] +end +T.eq(world:canFly(), true, "recursive capability probe terminates") +ow.partyKnows = function() + ow.player.cellX = ow.player.cellX + 1 + return game.save.party[1] +end +T.eq(world:flyTo("DEST"), nil, "eligibility hook changing source position cancels action") +T.eq(#calls, 2, "changed source never reaches native handler") + +local nestedApi, nestedGame, nestedWorld = boot("red") +nestedWorld.flyTo = function() + T.eq(nestedApi.world:reorderParty(1, 2), nil, "native dispatch callback cannot reorder concurrently") + T.eq(nestedApi.world:flyTo("DEST"), nil, "native dispatch callback cannot start a second flight") + nestedWorld.flyAnim = {} +end +T.eq(nestedApi.world:flyTo("DEST"), true, "guarded native dispatch still starts once") +nestedWorld.flyAnim = nil +local nativeError = { native = true } +nestedWorld.flyTo = function() + error(nativeError) +end +local okNative, errNative = pcall(function() + nestedApi.world:flyTo("DEST") +end) +T.check(not okNative and errNative == nativeError, "native error identity retained") +T.eq(nestedApi.world:canReorderParty(), true, "throwing handler releases only the API dispatch lock") + +local empty = WorldAPI.new(nil, "empty") +T.eq(empty:reorderParty(1, 2), nil, "missing game mutation safe") +T.eq(empty:canFly(), false, "missing game probe safe") +T.same(empty:availableFieldActions(), {}, "missing game has no field actions") +for _, name in ipairs({ + "canReorderParty", + "reorderParty", + "availableFieldActions", + "useFieldAction", + "canFly", + "flyTo", +}) do + T.check(Actions.supports(name), "action capability " .. name) +end +T.eq(Actions.supports("party"), false, "reader capability remains separate") +GameVersion.set(saved[1]) +Runtime.install(saved[2], saved[3], saved[4]) +love = saved[5] +Gen3Show.show = saved[6] +T.finish() From 59348147b706bad091d095ef247c100b6438b2d9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:44:35 +0200 Subject: [PATCH 06/71] Allow capability tests to follow additive native flight support --- tests/engine/mod_supports.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index 7e3bf96f5..5d4562cd8 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -5,6 +5,7 @@ local T = require('tests.harness') local GameVersion = require('src.core.GameVersion') local Loader = require('src.mods.Loader') local Runtime = require('src.mods.Runtime') +local WorldAPI = require('src.world.WorldAPI') local Schemas = require('src.mods.Schemas') local Catalog = require('tests.modkit.catalog') local originalEvents, originalHooks, originalErrors = Runtime.events, Runtime.hooks, Runtime.errors @@ -72,7 +73,9 @@ for _, version in ipairs(GameVersion.ORDER) do registries = registries and yes(api, 'content', name) end T.check(registries, version .. ': all API-2 registration surfaces available') - T.check(no(api, 'world', 'flyTo') and no(api, 'hooks', 'core.logic_speed') + T.eq(yes(api, 'world', 'flyTo'), type(WorldAPI.flyTo) == 'function', + version .. ': additive native flight interface is reported when installed') + T.check(no(api, 'hooks', 'core.logic_speed') and no(api, 'hooks', 'item.use') and no(api, 'events', 'egg.hatched'), version .. ': absent Gen1Recomp API-3 features are not advertised') T.check(yes(api, 'events', 'mod.probe.ready') and yes(api, 'events', 'mod.other.ready'), From 52b0055ef9679e31e9cc0f24069c87e50c1f4401 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:05:23 +0200 Subject: [PATCH 07/71] Add detached read-only battle snapshots for mods --- docs/battle-snapshot-api.md | 119 ++++++ docs/modding.md | 9 + src/battle/BattleState.lua | 2 + src/mods/BattleSnapshots.lua | 472 +++++++++++++++++++++++ src/mods/Loader.lua | 20 +- src/mods/UnifiedIds.lua | 85 +++++ tests/engine/battle_snapshots.lua | 613 ++++++++++++++++++++++++++++++ 7 files changed, 1316 insertions(+), 4 deletions(-) create mode 100644 docs/battle-snapshot-api.md create mode 100644 src/mods/BattleSnapshots.lua create mode 100644 src/mods/UnifiedIds.lua create mode 100644 tests/engine/battle_snapshots.lua diff --git a/docs/battle-snapshot-api.md b/docs/battle-snapshot-api.md new file mode 100644 index 000000000..2e9266704 --- /dev/null +++ b/docs/battle-snapshot-api.md @@ -0,0 +1,119 @@ +# Battle snapshot API + +```lua +local battle = mod.battle -- safe in the mod entry chunk, before Game exists +local snapshot, reason = battle:snapshot() +if snapshot then + local actor = snapshot.player + if actor then print(actor.name, actor.hp, actor.maxHp) end +end +``` + +Available to API 1 and API 2 mods on all supported Gen 1–5 versions. Neither +`content_format` nor `callback_format` is required. This reads the shared +BattleState in the current Game's stack, including beneath another screen. +A read before Game is loaded also returns `nil, "no battle"` without +bootstrapping Game. Outside battle it returns `nil, "no battle"`. A UI screen merely holding a +`.battle` reference does not count as another battle. + +If the capability-query extension is installed, +`mod:supports("battle", "snapshot")` returns true without constructing Game. +`submit` and other battle capabilities return false with a reason. + +## Records + +All returned tables, including nested records and `native` diagnostics, are +copies. They carry no engine objects, functions, userdata, threads or +metatables. Unknown native Pokemon extension fields are copied as data; +cycles are preserved. These diagnostics are not a portable write format or +necessarily JSON-serializable. Changing a snapshot never changes battle/save +state or future reads. + +| Field | Meaning | +| --- | --- | +| `revision` | Positive, monotonically increasing within this facade; see below | +| `kind`, `phase`, `result` | Battle kind (including ghost/safari/oldman variants), native phase and optional outcome | +| `turn` | Native resolved-turn count; zero before the first turn | +| `double` | Whether this battle is configured as a double | +| `active` | Player position the menu currently selects for (0 or 2) | +| `player` | Occupied battler at `active`, or nil | +| `enemy` | Occupied opponent-left battler (1), or nil; use `battlers` for all foes | +| `battlers` | Array of occupied battler records in position order | +| `party` | Battle-specific player party when supplied (link copies), otherwise save party order, with one-based `slot` and `active` | +| `moves` | Current actor's battle moves, including Transform/Mimic overrides | +| `items` | Positive inventory quantities; acquisition order, then sorted unlisted IDs | +| `catchable` | Wild battle, one living foe, no ghost/noCatch/result restriction | +| `prompt` | Description of the battle screen's input phase; see below | +| `message` | Optional array of currently revealed text lines; unknown glyphs leave it unavailable | +| `messageSource` | Optional current message source text, which can include unrevealed text | +| `safariBalls` | Optional native remaining Safari ball count | +| `menuIndex`, `moveIndex`, `moveSwapIndex` | Optional one-based native menu/move/reorder cursors | +| `targets` | During targetSelect, ordered positional IDs from the native selector; otherwise empty | +| `targetIndex` | Optional one-based cursor into `targets` | +| `mimicMoves`, `mimicIndex` | Optional Mimic options and one-based cursor | + +Battler IDs are **0 player-left, 1 opponent-left, 2 player-right, 3 +opponent-right**. Array indices are not battler IDs. Empty positions are +omitted; an enemy at 3 never masquerades as enemy-left. `player` follows the +second player chooser, including after the left flank leaves the field. + +Pokemon records expose `species`, optional `item`, `nickname`, `name`, +`level`, `hp`, `maxHp`, `status`, `isEgg`, `stats`, `moves`, and copied `native` +diagnostics. Species/move/item IDs use canonical string names from this boot's +registries. Numeric DS IDs resolve through the imported names and aliases; +unresolved IDs use their string form. Pristine IDs remain stable across +presentation-name patches; definitions registered by later mods are visible. +Status codes SLP/PSN/BRN/FRZ/PAR/TOX become +sleep/poison/burn/freeze/paralyze/toxic; unknown values are retained. + +Stats use `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, +`specialDefense`; Gen 1's special supplies both split stats. `maxHp` comes +from stored `stats.hp`. HP and status are logical state, not interpolated HUD +values. Battlers additionally expose `id`, `side` (player/enemy), `active`, +`absent` (false for occupied rows), `ally`, optional one-based `partyIndex`, +`displaySpecies`, `battleStats`, `types`, and `stages`. Original species and +stored stats stay separate from Transform's displayed species/current stats. +NPC allies are not added to the player's party. + +Moves expose one-based `slot`, portable `id`, optional `name`, `pp`, `maxPp`, +`ppUps`, `type`, `power`, `accuracy`. Max PP follows the native base PP plus +clamped 0–3 PP Ups, each adding floor(base PP / 5). Definition fields are base +values, not damage/accuracy predictions. Only the top-level actor `moves` +also expose `disabled`: true for Disable, no PP, Taunt or Torment; false if +none applies on Gen 1–4. In BW this field is omitted unless a common +restriction is known, because additional native Choice/Imprison/Gravity/ +Heal Block controllers may refuse the move. Nil is unknown, not false. +Mimic options retain their original move `slot` even if options are compacted. + +Items expose `id`, optional `name`, `count`, `ball`, `catchable`, optional +`pocket`. This is an **inventory view**, not a list of currently usable items; +it can include badges/key items. Ball classification follows imported ball +metadata. `catchable` describes the target restriction, not permission to use +an item: noItems/frontier rules, party capacity and the active bag controller +still own item eligibility. No `needsTarget`, catchChance, hitChance, +displayPower, effectiveness or guessed preview is produced. + +## Prompts and revisions + +`prompt` is `menu`, `moves`, `target`, `mimic`, `safari`, `advance`, or +`locked`. Intro, automatic demos/Palace, conservative Arena menu handling, +known automatic battler locks and every covered battle are locked. Message +advance is reported only when native manual text waits have finished their +pre-advance delay. Party, bag and choice screens above battle stay locked; +this API does not inspect or drive those screens. Link controllers remain locked. +Visible messages decode the native revealed glyph buffer through the imported +charmap and already-loaded Unicode glyph map; this never initializes a font. A prompt is descriptive, +not an action-permission token. There is no battle input/submission API. + +Identical successive snapshot records retain their revision. A change to +any exposed data advances it once at the next read; animation clocks and +interpolated HP alone do not. A different battle or Game also advances it, +even with identical records. Observing `no battle` invalidates the previous +record, so re-entry advances it. Unobserved transitions between reads cannot +be detected if the same Game and battle objects are reused. Each mod has its +own counter; revisions are not save data or globally synchronized IDs. + +Reading constructs no Pokemon or screen, loads no gameplay/graphics module, +executes no action, emits no event/hook and consumes no RNG. Only facade +bookkeeping and loader-local ID indexes change. The API provides an observer +boundary, not hidden-information filtering or a write-back transaction. diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..dbbfadc75 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -152,6 +152,15 @@ Three rules worth knowing: Returning `nil` from `drawWorld` is a normal answer meaning "not this frame"; the engine draws the vanilla world instead. +## Read-only battle snapshots + +`mod.battle:snapshot()` returns a detached common battle view on Gen 1–5, +including singles and doubles, or `nil, "no battle"`. It can be captured in +an entry chunk before Game exists. See [Battle snapshot API](battle-snapshot-api.md) +for records, revisions, prompt semantics and intentionally omitted previews. +It reads state only; there is no `submit` method. When capability queries are +installed, `mod:supports("battle", "snapshot")` reports this interface. + ## Battle sprite scaling The enemy's front pic draws at 1x and the player's back pic at 2x, the way diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9688aa08f..c72bad08e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -53,6 +53,8 @@ local TEXT_AREA_ROWS = { 112, 128 } local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +-- Data readers can identify the shared Gen 1-5 battle without loading this module. +BattleState.isBattleState = true -- pokered prints the battle lines itself (engine/battle/core.asm and the -- move-effect banks), and the importer extracts every one of them, so the diff --git a/src/mods/BattleSnapshots.lua b/src/mods/BattleSnapshots.lua new file mode 100644 index 000000000..79a0aa417 --- /dev/null +++ b/src/mods/BattleSnapshots.lua @@ -0,0 +1,472 @@ +-- A data-only view of the shared Gen 1-5 battle. Never call battle/menu, +-- item-effect or preview helpers here: some of those consume RNG or write state. +local Ids = require("src.mods.UnifiedIds") +local Snapshots = {} + +local STATUS = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local STATS = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} + +local function copy(value, seen) + local kind = type(value) + if kind ~= "table" then + if kind == "string" or kind == "boolean" or kind == "number" then + return value + end + return nil + end + seen = seen or {} + if seen[value] then + return seen[value] + end + local out = {} + seen[value] = out + for key, v in pairs(value) do + if type(key) == "string" or type(key) == "number" then + out[key] = copy(v, seen) + end + end + return out +end + +local function equal(a, b, seen) + if type(a) ~= type(b) then + return false + end + if type(a) ~= "table" then + return a == b or (a ~= a and b ~= b) + end + seen = seen or {} + if seen[a] then + return seen[a] == b + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end + +local function stats(raw) + local out = {} + for public, native in pairs(STATS) do + out[public] = (raw or {})[native] + end + out.specialAttack = out.specialAttack or out.special + out.specialDefense = out.specialDefense or out.special + return out +end + +local function at(battle, pos) + -- Constructors fill these aliases before enter()/syncSides populates the + -- positional arrays. A cleared alias also truthfully represents a faint. + if pos == 0 then + return battle.player + end + if pos == 1 then + return battle.enemy + end + local side = battle.sides and battle.sides[pos % 2 + 1] + return side and side.battlers and side.battlers[math.floor(pos / 2) + 1] +end + +local function activeSlot(battle) + if not battle.double then + return 0 + end + local pos = battle.choosingSlot + if (pos == 0 or pos == 2) and at(battle, pos) then + return pos + end + for _, id in ipairs({ 0, 2 }) do + local b = at(battle, id) + if b and not b.ally then + return id + end + end + return 0 +end + +local function moveView(move, slot, data, ids) + local id = type(move) == "table" and move.id or move + local def = (data.moves or {})[id] or {} + local ups = type(move) == "table" and math.min(3, math.max(0, math.floor(tonumber(move.ppUps) or 0))) or 0 + return { + slot = slot, + id = ids:read("moves", id), + name = def.name, + pp = type(move) == "table" and move.pp or nil, + maxPp = def.pp and (def.pp + ups * math.floor(def.pp / 5)) or nil, + ppUps = ups, + type = def.type, + power = def.power, + accuracy = def.accuracy, + } +end + +local function monView(raw, data, ids) + if not raw then + return nil + end + local out = { + species = ids:read("pokemon", raw.species), + item = ids:read("items", raw.item), + nickname = raw.nickname, + name = raw.name or raw.nickname or ((data.pokemon or {})[raw.species] or {}).name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = STATUS[raw.status] or raw.status, + isEgg = raw.isEgg == true, + stats = stats(raw.stats), + moves = {}, + native = copy(raw), + } + for slot, move in ipairs(raw.moves or {}) do + out.moves[slot] = moveView(move, slot, data, ids) + end + return out +end + +local function battlerView(b, pos, party, data, ids) + if not (b and b.mon) then + return nil + end + local out = monView(b.mon, data, ids) + out.name = b.name or out.name + out.id, out.side, out.active, out.absent = pos, pos % 2 == 0 and "player" or "enemy", true, false + out.displaySpecies = ids:read("pokemon", b.species or b.mon.species) + out.battleStats, out.types, out.stages = + stats(b.curStats or b.mon.stats), copy(b.curTypes), copy(b.stages or {}) + out.ally = b.ally == true + for index, mon in ipairs(party) do + if mon == b.mon then + out.partyIndex = index + break + end + end + out.moves = {} + for slot, move in ipairs(b.curMoves or b.mon.moves or {}) do + out.moves[slot] = moveView(move, slot, data, ids) + end + return out +end + +-- Prompt describes the battle screen only. Covered screens and automatic +-- controllers stay locked; no attempt is made to inspect or drive their UI. +local function prompt(b, covered, actor) + if + covered + or b.result + or b.waitingUI + or b.demo + or b.pokedudeDemo + or b.palace + or b.kind == "link" + or (b.arena and b.phase == "menu") + then + return "locked" + end + if b.phase == "menu" then + if b.safari then + return (b.safari.balls or 0) > 0 and "safari" or "locked" + end + if + not (actor and actor.mon and (actor.mon.hp or 0) > 0) + or actor.ally + or actor.mustRecharge + or actor.charging + or actor.rageMove + or (actor.thrashTurns and actor.thrashTurns > 0) + then + return "locked" + end + -- Sky Drop's controller in BW owns the menu; unknown native locks are + -- not advertised as actionable (this API never submits input). + if b.bw and actor.bwSkyDropped then + return "locked" + end + return "menu" + end + if b.phase == "moveSelect" then + return "moves" + end + if b.phase == "targetSelect" then + return "target" + end + if b.phase == "mimicSelect" then + return "mimic" + end + if + b.phase == "messages" + and b.current + and not b.current.choice + and ((b.msgWaiting and (b.msgPreWait or 0) <= 0) or (b.msgPrompt and (b.msgPromptWait or 0) <= 0)) + then + return "advance" + end + return "locked" +end + +-- Decode only glyphs already revealed by the native message clock. Unknown +-- glyphs leave the optional visible message unavailable, never reveal ahead. +local function visibleMessage(battle, covered, data) + if covered or battle.phase ~= "messages" or not battle.shown then + return nil + end + local reverse = {} + local function add(rows) + for _, row in ipairs(rows or {}) do + if type(row.code) == "number" and type(row.seq) == "string" and reverse[row.code] == nil then + reverse[row.code] = row.seq + end + end + end + add(data.font and data.font.charmap) + local pages, names = data.font and data.font.pages or {}, {} + for name in pairs(pages) do + names[#names + 1] = name + end + table.sort(names) + for _, name in ipairs(names) do + add(type(pages[name]) == "table" and pages[name].charmap) + end + local unicode = package.loaded["src.translation.UnicodeFont"] + local chars = type(unicode) == "table" and unicode.chars or {} + local scaffold = data.font + and type(data.font.source) == "string" + and data.font.source:find("Gen2 scaffold", 1, true) + local out = {} + for i, codes in ipairs(battle.shown) do + local line = {} + for _, code in ipairs(codes) do + local seq = chars[code] or reverse[code] + if scaffold and type(code) == "number" and code >= 0 and code <= 255 then + seq = string.char(code) + end + if not seq then + return nil + end + line[#line + 1] = seq + end + out[i] = table.concat(line) + end + return out +end + +local function kind(battle) + -- The same variants as BattleState:battleKind(), without a method call. + if battle.ghost then + return "ghost" + end + if battle.safari then + return "safari" + end + if battle.demo and not battle.dudeDemo then + return "oldman" + end + return battle.kind +end + +local function build(game, battle, covered, ids) + local data, save = battle.data or game.data or {}, game.save or {} + local party, enemyParty = battle.playerParty or save.party or {}, battle.enemyParty or {} + local active = activeSlot(battle) + local actor = at(battle, active) + local out = { + kind = kind(battle), + phase = battle.phase, + double = battle.double == true, + active = active, + turn = battle.turnCount or 0, + prompt = prompt(battle, covered, actor), + message = visibleMessage(battle, covered, data), + messageSource = battle.current and battle.current.text, + safariBalls = battle.safari and battle.safari.balls, + menuIndex = battle.menuIndex, + moveIndex = battle.moveIndex, + moveSwapIndex = battle.moveSwapIndex, + result = battle.result, + party = {}, + battlers = {}, + moves = {}, + items = {}, + targets = {}, + } + local foes = 0 + local rows = {} + for pos = 0, (out.double and 3 or 1) do + local b = at(battle, pos) + local row = battlerView( + b, + pos, + pos % 2 == 0 and (b and b.ally and battle.ally and battle.ally.party or party) or enemyParty, + data, + ids + ) + rows[pos] = row + if row then + out.battlers[#out.battlers + 1] = row + if pos % 2 == 1 and (row.hp or 0) > 0 then + foes = foes + 1 + end + end + end + out.player, out.enemy = rows[active], rows[1] + out.catchable = battle.kind == "wild" + and not battle.ghost + and not battle.noCatch + and not battle.result + and foes == 1 + for slot, raw in ipairs(party) do + local row = monView(raw, data, ids) + row.slot, row.active = slot, false + for _, pos in ipairs({ 0, 2 }) do + local b = at(battle, pos) + if b and not b.ally and b.mon == raw then + row.active = true + end + end + out.party[slot] = row + end + for slot, move in ipairs(actor and (actor.curMoves or actor.mon.moves) or {}) do + local row = moveView(move, slot, data, ids) + local def = (data.moves or {})[move.id] or {} + -- The three explicit guards used by BattleState's move selector. This + -- is a local menu restriction, not a guarantee a move will execute. + row.disabled = actor.disabledSlot == slot + or (move.pp or 0) <= 0 + or (actor.tauntTurns ~= nil and (def.power or 0) == 0) + or (actor.tormented and actor.lastMove ~= nil and move.id == actor.lastMove) + or false + -- BW adds Choice/Imprison/Gravity/Heal Block checks through native + -- controllers. Do not report an unverified false for those moves. + if battle.bw and not row.disabled then + row.disabled = nil + end + out.moves[slot] = row + end + -- Inventory quantities, not a use-eligibility result. Keep acquisition + -- order, then append positive unlisted IDs deterministically. + local inventory, seen, remaining = save.inventory or {}, {}, {} + local function item(id) + if seen[id] then + return + end + seen[id] = true + local count = inventory[id] + if type(count) ~= "number" or count <= 0 then + return + end + local def = (data.items or {})[id] or {} + local balls = data.balls or {} + local ball = def.ball ~= nil and def.ball ~= false or balls[id] ~= nil or balls[tostring(id)] ~= nil + out.items[#out.items + 1] = { + id = ids:read("items", id), + name = def.name, + count = count, + ball = ball, + catchable = ball and out.catchable, + pocket = def.pocket, + } + end + for _, id in ipairs(save.bagOrder or {}) do + item(id) + end + for id in pairs(inventory) do + if not seen[id] then + remaining[#remaining + 1] = id + end + end + table.sort(remaining, function(a, b) + return type(a) == type(b) and a < b or type(a) < type(b) + end) + for _, id in ipairs(remaining) do + item(id) + end + if battle.phase == "targetSelect" then + for _, target in ipairs(battle.targetChoices or {}) do + for pos = 0, 3 do + if at(battle, pos) == target then + out.targets[#out.targets + 1] = pos + break + end + end + end + out.targetIndex = battle.targetIndex + end + if battle.phase == "mimicSelect" then + out.mimicMoves, out.mimicIndex = {}, battle.mimicIndex + for index, move in ipairs(battle.mimicMoves or {}) do + out.mimicMoves[index] = moveView(move, move.slot or index, data, ids) + end + end + return out +end + +function Snapshots.new(gameProvider, loader) + -- Ids keeps alias metadata on the loader, never on gameplay records. + local initial = Ids.new(loader) + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + initial:entries(kind) + end + local facade = {} + local lastGame, lastBattle, previous, revision = nil, nil, nil, 0 + function facade:snapshot() + local game = gameProvider() + local states = game and game.stack and game.stack.states or {} + local battle, index + for i = #states, 1, -1 do + if states[i].isBattleState == true then + battle, index = states[i], i + break + end + end + if not battle then + lastGame, lastBattle, previous = nil, nil, nil + return nil, "no battle" + end + local out = copy(build(game, battle, index ~= #states, Ids.new(loader))) + if game ~= lastGame or battle ~= lastBattle or not equal(out, previous) then + revision = revision + 1 + end + lastGame, lastBattle, previous = game, battle, out + local result = copy(out) + result.revision = revision + return result + end + return facade +end +-- Run after construction so an optional capability provider and its other +-- surface wrappers are already installed. Standalone main has no provider. +function Snapshots.withCapabilities(api) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "battle" then + if name == "snapshot" then + return true + end + return false, "unknown battle capability: " .. tostring(name) + end + return supports(self, surface, name) + end + end + return api +end +return Snapshots diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..08e71b498 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -21,6 +21,7 @@ local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") local ModStorage = require("src.mods.Storage") local Runtime = require("src.mods.Runtime") +local BattleSnapshots = require("src.mods.BattleSnapshots") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") -- Module-name aliases for mods written against the Gold port's per-generation @@ -841,6 +842,8 @@ function Loader:_api(mod) if okN then hostInfo = info end end end + -- Capture pristine ID aliases before mod merges; the facade resolves Game only on a read. + local battle = BattleSnapshots.new(function() return loader:_game(true) end, loader) local engineVersion do local okE, V = pcall(require, "src.core.Version") @@ -868,6 +871,7 @@ function Loader:_api(mod) -- returns the same module table the engine holds, so a mod that mutates -- one is changing the engine, exactly as it always was. engineRequire = engineRequire, + battle = battle, -- 1 to 5. The one field worth reaching for before anything else. generation = hostGen, -- the version id the launcher started: "red", "crystal", "emerald", @@ -1120,8 +1124,10 @@ function Loader:_api(mod) return data end) -- mod.world / mod.game / mod.storage all materialize on first touch, for - -- the same reason: a headless load must not drag the world stack in, and - -- the Game the facade acts on is still being wired when the entry chunk + -- the same reason: a headless load must not drag the world stack in. + -- Battle capability queries can also be installed before Game exists. + BattleSnapshots.withCapabilities(api) + -- The Game the facade acts on is still being wired when the entry chunk -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. local world, storage @@ -1148,8 +1154,14 @@ end -- the live Game. An injected reference wins so a headless caller can hand -- over a stub; otherwise the boot singleton, whose stack and overworld fill -- in after this loader returns -- holding the table keeps the facade live. -function Loader:_game() - return self.game or engineRequire("src.core.Game") +function Loader:_game(loadedOnly) + if self.game then return self.game end + -- Observational facades must not bootstrap Game from an early/headless read. + if loadedOnly then + local game = package.loaded["src.core.Game"] + return type(game) == "table" and game or nil + end + return engineRequire("src.core.Game") end function Loader:_loadMod(mod) diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/tests/engine/battle_snapshots.lua b/tests/engine/battle_snapshots.lua new file mode 100644 index 000000000..27c992c5f --- /dev/null +++ b/tests/engine/battle_snapshots.lua @@ -0,0 +1,613 @@ +-- ROM-free contract tests through the real mod loader and BattleState. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local BattleState = require("src.battle.BattleState") +local GameVersion = require("src.core.GameVersion") +local Merge = require("src.mods.Merge") +local Json = require("src.link.Json") +local oldVersion, oldLove = GameVersion.get(), love +love = nil + +-- Exercise the real loader resolver, including Lua 5.1's in-progress marker. +local cachedGame = package.loaded["src.core.Game"] +package.loaded["src.core.Game"] = nil +T.eq(Loader._game({}, true), nil, "loaded-only lookup does not bootstrap Game") +T.eq(package.loaded["src.core.Game"], nil, "lookup leaves module cache untouched") +package.loaded["src.core.Game"] = true +T.eq(Loader._game({}, true), nil, "in-progress Game is unavailable") +local injected = {} +T.eq(Loader._game({ game = injected }, true), injected, "explicit Game takes precedence") +package.loaded["src.core.Game"] = injected +T.eq(Loader._game({}, true), injected, "live cached Game is available") +package.loaded["src.core.Game"] = cachedGame + +local function boot(version, level) + GameVersion.set(version) + local ds = GameVersion.generation() >= 4 + local s, m, item = ds and 25 or "PIKACHU", ds and 33 or "TACKLE", ds and 4 or "POKE_BALL" + local species, move = + { name = "Pikachu", types = { "ELECTRIC" } }, + { name = "Tackle", pp = 35, power = 40, accuracy = 95, type = "NORMAL" } + local data = { + pokemon = { [s] = species }, + moves = { [m] = move }, + items = { + [item] = { name = "Poké Ball", ball = "POKE_BALL" }, + Z_EXTRA = { name = "Extra" }, + A_EXTRA = { name = "Earlier" }, + }, + } + if ds then + data.pokemon.SPECIES_25 = species + data.moves.MOVE_33 = move + end + local function mon(nick) + return { + species = s, + nickname = nick, + level = 7, + hp = 19, + status = "PSN", + stats = { hp = 23, attack = 10, defense = 11, special = 12, speed = 13 }, + moves = { { id = m, pp = 29, ppUps = 1 } }, + isEgg = false, + extension = { flag = false }, + } + end + local a, b, e, f = mon("Left"), mon("Right"), mon("Foe"), mon("Other foe") + if GameVersion.generation() > 1 then + for _, raw in ipairs({ a, b, e, f }) do + raw.stats.spatk, raw.stats.spdef = 14, 15 + end + end + local party = { a, b } + local function battler(raw) + return { + mon = raw, + name = raw.nickname, + species = raw.species, + curStats = raw.stats, + curTypes = { "ELECTRIC" }, + curMoves = raw.moves, + stages = {}, + } + end + local game = { + data = data, + save = { + party = party, + inventory = { + [item] = 3, + Z_EXTRA = 2, + A_EXTRA = 1, + ZERO = 0, + }, + bagOrder = { item, item, "ZERO" }, + }, + stack = { states = {} }, + } + local btl = setmetatable({ + game = game, + data = data, + kind = "wild", + phase = "menu", + queue = {}, + player = battler(a), + enemy = battler(e), + enemyParty = { e, f }, + turnCount = 0, + sides = { { battlers = {} }, { battlers = {} } }, + }, BattleState) + btl:placeBattler(0, btl.player) + btl:placeBattler(1, btl.enemy) + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = level or 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = [[return function(mod) + assert(type(mod.battle.snapshot)=="function") + mod.exports.api=mod + mod.content.moves:register("MOVE_9000", {id="MOVE_9000", name="Late move", pp=10, power=10, + accuracy=100, type="NORMAL", effect="NO_ADDITIONAL_EFFECT"}) + end]], + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + return (p == "mods" or p == "mods/probe") and { type = "directory" } or files[p] and { type = "file" } + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function() + resolves = resolves + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return loader.apis.probe, loader, game, btl, battler, { s, m, item }, function() + return resolves + end +end + +-- Freeze the whole graph as table identities + raw entries, including cycles. +local function freeze(root) + local graph = {} + local function visit(value) + if type(value) ~= "table" or graph[value] then + return + end + local entries = {} + graph[value] = entries + for key, child in pairs(value) do + entries[key] = child + visit(child) + end + end + visit(root) + return function() + for tbl, entries in pairs(graph) do + for key, value in pairs(entries) do + if tbl[key] ~= value then + return false + end + end + for key in pairs(tbl) do + if entries[key] == nil then + return false + end + end + end + return true + end +end + +local function forbidden() + error("snapshot crossed into gameplay") +end +local oldRandom = math.random +math.random = forbidden +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, game, b, battler, ids, resolves = boot(version) + local label = version .. ": " + T.eq(resolves(), 0, label .. "entry can capture facade before Game exists") + if api.supports then + T.eq(api:supports("battle", "snapshot"), true, label .. "snapshot capability") + T.eq(api:supports("battle", "submit"), false, label .. "no input submission") + T.eq(resolves(), 0, label .. "probe does not resolve Game") + end + T.eq(api.battle, api.battle, label .. "stable facade") + T.eq(api.battle.submit, nil, label .. "no mutation method") + local no, reason = api.battle:snapshot() + T.eq(no, nil, label .. "no battle") + T.eq(reason, "no battle", label .. "no battle reason") + game.stack.states = { { battle = b, phase = "menu", player = b.player, enemy = b.enemy } } + T.eq(api.battle:snapshot(), nil, label .. "a UI holding battle is not battle") + game.stack.states = { b } + b.rng = forbidden + b.menuLockedAction, b.fightLockedAction, b.menuBattler, b.playerHasPP = + forbidden, forbidden, forbidden, forbidden + b.catchAttempt, b.ballDef, b.moveDef, b.choosingSlotNow = forbidden, forbidden, forbidden, forbidden + b.volatile, b.enemyAction, b.update, b.draw = forbidden, forbidden, forbidden, forbidden + local unchanged = freeze(game) + local snap = api.battle:snapshot() + T.check(unchanged(), label .. "complete game graph unchanged") + T.eq(snap.revision, 1, label .. "first revision") + T.eq(snap.prompt, "menu", label .. "menu phase") + T.eq(snap.double, false, label .. "single battle") + T.eq(snap.active, 0, label .. "left actor") + T.eq(snap.kind, "wild", label .. "wild kind") + T.eq(snap.turn, 0, label .. "before first turn") + T.eq(snap.catchable, true, label .. "single wild target") + T.eq(#snap.battlers, 2, label .. "both occupied slots") + T.eq(snap.player.species, "PIKACHU", label .. "portable species") + T.eq(snap.player.name, "Left", label .. "battler display name") + T.eq(snap.player.hp, 19, label .. "logical HP") + T.eq(snap.player.maxHp, 23, label .. "max HP") + T.eq(snap.player.status, "poison", label .. "portable status") + T.eq(snap.player.stats.specialAttack, GameVersion.generation() == 1 and 12 or 14, label .. "special attack") + T.eq( + snap.player.stats.specialDefense, + GameVersion.generation() == 1 and 12 or 15, + label .. "special defense" + ) + T.eq(snap.player.partyIndex, 1, label .. "party slot by identity") + T.eq(snap.party[1].active, true, label .. "lead active") + T.eq(snap.party[2].active, false, label .. "bench inactive") + T.eq(snap.party[2].slot, 2, label .. "party slot one based") + T.eq(snap.moves[1].id, "TACKLE", label .. "portable move") + T.eq(snap.moves[1].maxPp, 42, label .. "PP Up maximum") + T.eq(snap.moves[1].pp, 29, label .. "current PP") + T.eq(snap.moves[1].disabled, false, label .. "unrestricted move") + T.eq(snap.moves[1].accuracy, 95, label .. "base accuracy") + T.eq(#snap.items, 3, label .. "positive inventory deduplicated") + T.eq(snap.items[1].id, "POKE_BALL", label .. "portable ball and bag order") + T.eq(snap.items[1].count, 3, label .. "inventory quantity") + T.eq(snap.items[1].ball, true, label .. "ball classification") + T.eq(snap.items[2].id, "A_EXTRA", label .. "unlisted inventory sorted") + T.eq(snap.items[3].id, "Z_EXTRA", label .. "stable inventory order") + T.eq(snap.items[1].catchChance, nil, label .. "no guessed catch preview") + T.eq(snap.items[1].needsTarget, nil, label .. "no guessed item eligibility") + T.same(api.battle:snapshot(), snap, label .. "unchanged reads stable") + snap.player.hp = 0 + snap.player.native.hp = 0 + snap.player.native.extension.flag = true + snap.moves[1].pp = 0 + snap.items[1].count = 0 + snap.party[1].stats.hp = 0 + snap.player.types[1] = "WATER" + snap.player.stages.attack = 6 + local clean = api.battle:snapshot() + T.eq(clean.revision, 1, label .. "caller writes do not alter revision") + T.eq(clean.player.hp, 19, label .. "caller writes isolated") + T.eq(clean.player.native.extension.flag, false, label .. "false preserved in diagnostics") + T.eq(clean.moves[1].pp, 29, label .. "move tables isolated") + T.eq(clean.items[1].count, 3, label .. "item tables isolated") + T.eq(clean.party[1].stats.hp, 23, label .. "party stats isolated") + T.eq(clean.player.types[1], "ELECTRIC", label .. "types isolated") + T.eq(clean.player.stages.attack, nil, label .. "stages isolated") + + b.frame, b.player.shownHP, b.player.picAnim = 99, 12, { tick = 5 } + T.eq(api.battle:snapshot().revision, 1, label .. "animation ticks excluded") + b.player.mon.hp = 18 + local changed = api.battle:snapshot() + T.eq(changed.revision, 2, label .. "HP changes revision") + T.eq(changed.player.hp, 18, label .. "new logical HP visible") + T.eq(clean.player.hp, 19, label .. "old result remains detached") + b.turnCount = 1 + T.eq(api.battle:snapshot().turn, 1, label .. "turn number updates") + local rev = api.battle:snapshot().revision + game.stack.states[#game.stack.states + 1] = { battle = b } + T.eq(api.battle:snapshot().prompt, "locked", label .. "covered battle locked") + T.check(api.battle:snapshot().revision > rev, label .. "coverage changes revision") + table.remove(game.stack.states) + local cases = { + { "intro", "locked" }, + { "messages", "locked" }, + { "moveSelect", "moves" }, + { "targetSelect", "target" }, + { "mimicSelect", "mimic" }, + { "finished", "locked" }, + } + for _, c in ipairs(cases) do + b.phase = c[1] + T.eq(api.battle:snapshot().prompt, c[2], label .. "phase " .. c[1]) + end + b.phase = "messages" + b.current = { text = "Current text" } + b.msgPrompt = true + b.msgPromptWait = 3 + T.eq(api.battle:snapshot().prompt, "locked", label .. "pre-advance delay") + b.msgPromptWait = 0 + T.eq(api.battle:snapshot().prompt, "advance", label .. "manual message advance") + T.eq(api.battle:snapshot().messageSource, "Current text", label .. "current message source") + T.eq(api.battle:snapshot().message, nil, label .. "no invented visible message") + game.data.font = + { charmap = { { code = 128, seq = "A" }, { code = 129, seq = "B" }, { code = 130, seq = "C" } } } + b.shown = { { 128, 129 }, { 130 } } + local text = api.battle:snapshot() + T.same(text.message, { "AB", "C" }, label .. "only revealed glyphs in visible message") + b.shown[2][2] = 128 + T.check(api.battle:snapshot().revision > text.revision, label .. "revealed glyph changes revision") + T.same(text.message, { "AB", "C" }, label .. "older visible message detached") + b.shown[1][1] = 999999 + T.eq(api.battle:snapshot().message, nil, label .. "unknown glyph is not guessed") + game.data.font.pages = { extra = { charmap = { { code = 999999, seq = "Ω" } } } } + T.same(api.battle:snapshot().message, { "ΩB", "CA" }, label .. "font page charmap read") + local unicode = package.loaded["src.translation.UnicodeFont"] + package.loaded["src.translation.UnicodeFont"] = { chars = { [-1] = "漢" } } + b.shown = { { -1 } } + T.same(api.battle:snapshot().message, { "漢" }, label .. "already loaded Unicode mapping read") + package.loaded["src.translation.UnicodeFont"] = unicode + game.data.font = { source = "Gen2 scaffold" } + b.shown = { { 65, 66 } } + T.same(api.battle:snapshot().message, { "AB" }, label .. "scaffold ASCII glyphs read") + game.stack.states[2] = { battle = b } + T.eq(api.battle:snapshot().message, nil, label .. "covered screen has no guessed visible message") + game.stack.states[2] = nil + b.shown = nil + b.current.choice = function() end + T.eq(api.battle:snapshot().prompt, "locked", label .. "choice is its own controller") + b.current = nil + b.msgPrompt = nil + b.phase = "menu" + for _, flag in ipairs({ "waitingUI", "demo", "pokedudeDemo", "palace", "arena" }) do + b[flag] = true + T.eq(api.battle:snapshot().prompt, "locked", label .. flag .. " locked") + b[flag] = nil + end + for _, flag in ipairs({ "mustRecharge", "charging", "rageMove", "thrashTurns" }) do + b.player[flag] = 1 + T.eq(api.battle:snapshot().prompt, "locked", label .. flag .. " locked") + b.player[flag] = nil + end + b.bw = true + b.player.bwSkyDropped = true + T.eq(api.battle:snapshot().prompt, "locked", label .. "Sky Drop lock") + b.bw = nil + b.player.bwSkyDropped = nil + b.safari = { balls = 3 } + T.eq(api.battle:snapshot().kind, "safari", label .. "Safari battle kind") + T.eq(api.battle:snapshot().safariBalls, 3, label .. "Safari ball count") + T.eq(api.battle:snapshot().prompt, "safari", label .. "Safari prompt") + b.safari.balls = 0 + T.eq(api.battle:snapshot().prompt, "locked", label .. "no Safari balls") + b.safari = nil + b.demo = true + T.eq(api.battle:snapshot().kind, "oldman", label .. "automatic capture demo kind") + b.demo = nil + b.ghost = true + T.eq(api.battle:snapshot().kind, "ghost", label .. "ghost kind") + b.ghost = nil + b.menuIndex = 2 + b.moveIndex = 3 + b.moveSwapIndex = 1 + local cursors = api.battle:snapshot() + T.eq(cursors.menuIndex, 2, label .. "menu cursor") + T.eq(cursors.moveIndex, 3, label .. "move cursor") + T.eq(cursors.moveSwapIndex, 1, label .. "reorder cursor") + b.menuIndex = 1 + T.check(api.battle:snapshot().revision > cursors.revision, label .. "cursor changes revision") + for _, flag in ipairs({ "ghost", "noCatch", "result" }) do + b[flag] = true + T.eq(api.battle:snapshot().catchable, false, label .. flag .. " uncatchable") + b[flag] = nil + end + b.kind = "trainer" + T.eq(api.battle:snapshot().catchable, false, label .. "trainer uncatchable") + b.kind = "wild" + b.phase = "moveSelect" + for _, guard in ipairs({ "disable", "pp", "taunt", "torment" }) do + if guard == "disable" then + b.player.disabledSlot = 1 + end + if guard == "pp" then + b.player.curMoves[1].pp = 0 + end + if guard == "taunt" then + b.player.tauntTurns = 2 + game.data.moves[ids[2]].power = 0 + end + if guard == "torment" then + b.player.tormented = true + b.player.lastMove = ids[2] + end + T.eq(api.battle:snapshot().moves[1].disabled, true, label .. guard .. " menu restriction") + b.player.disabledSlot = nil + b.player.curMoves[1].pp = 29 + b.player.tauntTurns = nil + game.data.moves[ids[2]].power = 40 + b.player.tormented = nil + b.player.lastMove = nil + end + -- Native alias keys may be split during merging; a name patch must not + -- rename a portable ID. Later registrations still appear in fresh reads. + game.data.pokemon[ids[1]].name = "Renamed presentation" + T.eq(api.battle:snapshot().player.species, "PIKACHU", label .. "name patches preserve identity") + local originalMoves = b.player.curMoves + b.player.curMoves = { { id = "MOVE_9000", pp = 8 } } + T.eq(api.battle:snapshot().moves[1].id, "LATE_MOVE", label .. "late mod ID resolved") + b.player.curMoves = originalMoves + b.bw = { field = {}, sides = { {}, {} } } + T.eq(api.battle:snapshot().moves[1].disabled, nil, label .. "BW eligibility remains unknown") + b.player.disabledSlot = 1 + T.eq(api.battle:snapshot().moves[1].disabled, true, label .. "BW known common restriction") + b.player.disabledSlot = nil + b.bw = nil + b.player.curMoves[1].ppUps = 99 + T.eq(api.battle:snapshot().moves[1].maxPp, 56, label .. "PP Ups clamped as native engine") + b.player.curMoves[1].ppUps = -1 + T.eq(api.battle:snapshot().moves[1].maxPp, 35, label .. "negative PP Ups clamped") + b.player.curMoves[1].ppUps = 1 + -- Transform changes the in-battle view while the stored party keeps its moves/stats. + game.data.moves.COPIED = { name = "Copied", pp = 10, power = 50, type = "WATER" } + b.player.curMoves = { { id = "COPIED", pp = 5 } } + b.player.curStats = { hp = 23, attack = 90, special = 80 } + b.player.species = "COPIED_SPECIES" + b.player.curTypes = { "WATER" } + local transformed = api.battle:snapshot() + T.eq(transformed.moves[1].id, "COPIED", label .. "current transformed move") + T.eq(transformed.party[1].moves[1].id, "TACKLE", label .. "stored party move") + T.eq(transformed.player.battleStats.attack, 90, label .. "current transformed stats") + T.eq(transformed.player.stats.attack, 10, label .. "base stats preserved") + T.eq(transformed.player.displaySpecies, "COPIED_SPECIES", label .. "display species separate") + T.eq(transformed.player.species, "PIKACHU", label .. "original species preserved") + -- All four native positional slots, including a missing left flank. + b.double = true + b:placeBattler(2, battler(game.save.party[2])) + b:placeBattler(3, battler(b.enemyParty[2])) + b.choosingSlot = 2 + b.phase = "menu" + local double = api.battle:snapshot() + T.eq(double.double, true, label .. "doubles flag") + T.eq(#double.battlers, 4, label .. "four occupied battlers") + T.eq(double.active, 2, label .. "second chooser") + T.eq(double.player.name, "Right", label .. "player follows chooser") + T.eq(double.player.partyIndex, 2, label .. "second chooser party index") + T.eq(double.catchable, false, label .. "cannot aim at two wild foes") + T.eq(double.party[2].active, true, label .. "both player slots active") + b.phase = "targetSelect" + b.targetChoices = { b.enemy, b.sides[2].battlers[2], b.player } + b.targetIndex = 2 + T.same(api.battle:snapshot().targets, { 1, 3, 0 }, label .. "target IDs, not array positions") + T.eq(api.battle:snapshot().targetIndex, 2, label .. "target cursor") + b:placeBattler(0, nil) + b:placeBattler(1, nil) + b.choosingSlot = nil + b.phase = "menu" + local missing = api.battle:snapshot() + T.eq(missing.active, 2, label .. "right flank after left faint") + T.eq(missing.enemy, nil, label .. "missing enemy left not silently aliased") + T.eq(missing.catchable, true, label .. "one wild foe remaining") + T.eq(#missing.battlers, 2, label .. "missing slots omitted") + T.eq(missing.battlers[1].id, 2, label .. "battler identity survives sparse slots") + b.phase = "mimicSelect" + b.mimicMoves = { { id = ids[2], slot = 3 } } + b.mimicIndex = 1 + local mimic = api.battle:snapshot() + T.eq(mimic.mimicMoves[1].id, "TACKLE", label .. "portable mimic ID") + T.eq(mimic.mimicMoves[1].slot, 3, label .. "original mimic move slot") + b.phase = "menu" + local ally = b.sides[1].battlers[2] + ally.ally = true + b.ally = { party = { ally.mon } } + local withAlly = api.battle:snapshot() + T.eq(withAlly.party[2].active, false, label .. "NPC ally not player active party") + T.eq(withAlly.battlers[1].ally, true, label .. "NPC ally marked") + T.eq(withAlly.prompt, "locked", label .. "NPC controlled actor menu locked") + ally.ally = nil + b.ally = nil + b.kind = "link" + b.playerParty = { game.save.party[2] } + local link = api.battle:snapshot() + T.eq(#link.party, 1, label .. "link uses clamped battle party") + T.eq(link.party[1].nickname, "Right", label .. "link party is actual participant") + T.eq(link.prompt, "locked", label .. "link controller owns input") + b.kind = "wild" + b.playerParty = nil + -- Snapshot diagnostics discard executable values and remain cycle-safe. + local raw = game.save.party[2] + raw.extension.self = raw.extension + raw.extension.fn = forbidden + raw.extension[{}] = "hidden" + setmetatable(raw.extension, { __index = { secret = true } }) + local cyc = api.battle:snapshot() + T.eq(cyc.party[2].native.extension.self, cyc.party[2].native.extension, label .. "cycles copied") + T.eq(cyc.party[2].native.extension.fn, nil, label .. "function dropped") + T.eq(getmetatable(cyc.party[2].native.extension), nil, label .. "metatable dropped") + T.eq(cyc.party[2].native.extension.secret, nil, label .. "no inherited value") + T.eq(api.battle:snapshot().revision, cyc.revision, label .. "cyclic record stable revision") + local last = cyc.revision + game.stack.states = {} + T.eq(api.battle:snapshot(), nil, label .. "battle exit") + game.stack.states = { b } + T.check(api.battle:snapshot().revision > last, label .. "re-entry invalidates revision") + last = api.battle:snapshot().revision + local replacement = setmetatable(Merge.deepCopy(b), BattleState) + game.stack.states = { replacement } + T.check(api.battle:snapshot().revision > last, label .. "identical new battle invalidates revision") + -- A previously captured facade follows the loader's current Game. + local other = { data = game.data, save = game.save, stack = game.stack } + loader._game = function() + return other + end + last = api.battle:snapshot().revision + loader._game = function() + return game + end + T.check(api.battle:snapshot().revision > last, label .. "Game replacement invalidates revision") +end +math.random = oldRandom + +-- Exercise constructors and a native damage action with the committed fixture +-- dataset, rather than asserting only hand-built record shapes. +love = require("tests.love_stub") +local Fixtures = require("tests.modkit.fixtures") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local function native(version, trainer) + local api, loader = boot(version) + love = require("tests.love_stub") + math.randomseed(12345) + local data = Fixtures.fresh() + Font.load(data) + local save = SaveData.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + local game = { + data = data, + save = save, + input = { + wasPressed = function() + return false + end, + }, + stack = { + states = {}, + push = function() end, + pop = function() end, + top = function(self) + return self.states[#self.states] + end, + }, + } + local b = trainer and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + or BattleState.newWild(game, "FIXMON_C", 10) + game.stack.states = { b } + loader._game = function() + return game + end + local calls = 0 + b.rng = function(_, high) + calls = calls + 1 + return high + end + return api, game, b, function() + return calls + end +end +for _, version in ipairs(GameVersion.ORDER) do + for _, trainer in ipairs({ false, true }) do + local api, game, b, calls = native(version, trainer) + local unchanged = freeze(game) + local rngBefore = calls() + local oldLoveRandom, oldLuaRandom = love.math.random, math.random + love.math.random, math.random = forbidden, forbidden + local Runtime = require("src.mods.Runtime") + local oldEmit, oldCall = Runtime.emit, Runtime.call + Runtime.emit, Runtime.call = forbidden, forbidden + local loaded = {} + for name in pairs(package.loaded) do + loaded[name] = true + end + local s = api.battle:snapshot() + for _ = 1, 10 do + T.eq(api.battle:snapshot().revision, s.revision, version .. ": repeated native snapshot stable") + end + local noNewModules = true + for name in pairs(package.loaded) do + if not loaded[name] then + noNewModules = false + end + end + T.check(noNewModules, version .. ": snapshot loads no modules") + Runtime.emit, Runtime.call = oldEmit, oldCall + T.check(unchanged(), version .. ": native constructor graph unchanged") + T.eq(calls(), rngBefore, version .. ": native RNG unchanged") + T.eq(s.kind, trainer and "trainer" or "wild", version .. ": native constructor kind") + T.eq(s.player.species, "FIXMON_A", version .. ": real Pokemon record") + T.eq(s.player.maxHp, b.player.mon.stats.hp, version .. ": constructor HP") + T.eq(s.moves[1].id, b.player.curMoves[1].id, version .. ": constructor moves") + T.eq(s.catchable, not trainer, version .. ": constructor catchability") + love.math.random, math.random = oldLoveRandom, oldLuaRandom + -- The same native action on an independently constructed control battle + -- must produce the same HP, PP, queue and RNG consumption. + local _, _, control, controlCalls = native(version, trainer) + b:performMove(b.player, b.enemy, b.player.curMoves[1]) + control:performMove(control.player, control.enemy, control.player.curMoves[1]) + T.eq(b.enemy.mon.hp, control.enemy.mon.hp, version .. ": action damage unaffected") + T.eq(b.player.curMoves[1].pp, control.player.curMoves[1].pp, version .. ": action PP unaffected") + T.eq(calls(), controlCalls(), version .. ": action RNG consumption unaffected") + T.eq(#b.queue, #control.queue, version .. ": action queue unaffected") + local later = api.battle:snapshot() + T.eq(later.enemy.hp, b.enemy.mon.hp, version .. ": snapshot follows native damage") + T.check(later.revision > s.revision, version .. ": native damage changes revision") + end +end +local api = boot("crystal", 1) +T.eq(type(api.battle.snapshot), "function", "API 1 mods also get additive reader") +GameVersion.set(oldVersion) +love = oldLove +T.finish("battle snapshots") From 904490919a64c3a4ba72914baecbb696c76cbd6f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:34:07 +0200 Subject: [PATCH 08/71] Add revision-bound public battle actions through native commands --- docs/battle-actions-api.md | 96 ++++ docs/battle-snapshot-api.md | 13 +- docs/modding.md | 4 +- src/battle/BattleCommands.lua | 355 ++++++++++++++ src/battle/BattleState.lua | 127 +---- src/mods/BattleIntents.lua | 178 +++++++ src/mods/BattleSnapshots.lua | 127 ++++- tests/engine/battle_actions.lua | 758 ++++++++++++++++++++++++++++++ tests/engine/battle_snapshots.lua | 4 +- 9 files changed, 1533 insertions(+), 129 deletions(-) create mode 100644 docs/battle-actions-api.md create mode 100644 src/battle/BattleCommands.lua create mode 100644 src/mods/BattleIntents.lua create mode 100644 tests/engine/battle_actions.lua diff --git a/docs/battle-actions-api.md b/docs/battle-actions-api.md new file mode 100644 index 000000000..f0e232773 --- /dev/null +++ b/docs/battle-actions-api.md @@ -0,0 +1,96 @@ +# Battle action API + +The action extension builds on `mod.battle:snapshot()` and is available to +API 1 and API 2 mods on the shared Gen 1–5 battle engine. No manifest opt-in +is required. If capability queries are installed, +`mod:supports("battle", "submit")` reports that this interface exists. It does +not promise the current battle accepts a particular command. + +```lua +local nextIntentId = 1 -- keep increasing for this captured facade +local snapshot = mod.battle:snapshot() +if snapshot and snapshot.prompt == "menu" then + local ok, reason = mod.battle:submit({ + id = nextIntentId, + revision = snapshot.revision, + kind = "menu", + choice = "fight", + }) + nextIntentId = nextIntentId + 1 + if not ok then mod.log:info("Battle command refused: %s", reason) end +end +``` + +Always fetch a fresh snapshot before the next command. Do not hold a revision +across a frame, other input, a native callback, another mod's command, a screen +change or a Game/battle replacement. The engine rechecks the live context; +returning to an apparently identical screen does not revive an old command. + +## Intent records + +Intents must be plain tables without a metatable. Every record contains +`id`, `revision`, `kind` and only the fields listed below. Integers must be +finite; `id` and `revision` are positive safe integers up to 2^53−1. Extra +fields, unknown kinds, strings in numeric fields and missing arguments are +rejected. The facade copies accepted input fields before calling native code. + +| `kind` | Extra field | Meaning | +| --- | --- | --- | +| `menu` | `choice`: fight / party / item / run | Select an action on the uncovered regular battle menu | +| `move` | `slot`: 1–4 | Select a current battle move; may enter the native target selector | +| `target` | `target`: 0–3 | Select a currently offered, occupied positional battler ID | +| `back` | none | Cancel move selection/reorder or targeting; in a double's second action menu undo the first choice when native B permits it | +| `safari` | `action`: ball / bait / rock / run | Select a native Safari command; `rock` is Platinum's mud button | +| `mimic` | `index`: 1–4 | Select a current Mimic **option index**, not the source move slot | + +Positions are 0 player-left, 1 opponent-left, 2 player-right, 3 opponent-right. +These are not indices into `snapshot.battlers` or `snapshot.targets`. Target +choices are checked again against the native selector and live field. A +pending move must still be the same current move and still pass move checks. + +`move` uses the actual chooser, including the right flank and Transform's +current move list. Native Disable, PP, Taunt, Torment and BW Choice/Imprison/ +Gravity/Heal Block restrictions are checked before accepting the request. +Mimic uses its queued user, including a right-flank user during turn resolution, +and preserves the native copy/restoration bookkeeping. + +`menu` opens the existing bag/party flow. It does not select a party member, +use an inventory item or throw a normal bag ball directly. Those screens keep +their own controller. Native forced FIGHT/Struggle, ghost refusals, trainer +RUN refusals and bug-contest item behavior are preserved. A valid menu choice +can therefore be accepted and produce a native refusal message. + +## Context, replay and errors + +`submit(intent)` returns `true` when admitted and dispatched; otherwise +`nil, reason`. Accepted means the native command path ran, not that a turn +succeeded, a move hit, a ball caught or a battle finished. + +An intent must refer to a snapshot actually returned by this facade and still +be current. Each facade owns its own monotonically increasing ID sequence; +IDs survive battle and Game changes. Once native dispatch starts, that ID is +consumed. Equal or lower IDs are refused as replayed. Skipping IDs is allowed. +A rejection before dispatch does not consume its ID; correct the problem and +read a new snapshot before retrying. + +All public facades on one Game share an in-progress guard and an invalidation +epoch outside gameplay/save state. Another mod or a callback cannot submit +recursively during validation or execution. Snapshots observed during execution +are locked and cannot authorize a command after it finishes. Accepted calls +invalidate other mods' old snapshots even if exposed data happens to stay the +same. These counters are local process bookkeeping, not link/save data. + +Native errors are caught, reported and clear the guard. **An entered native +call is not retried or rolled back.** Its ID stays consumed even if native +code throws after a partial effect or returns a refusal. Fetch the current +snapshot and use a new ID for any deliberate next action. Validation errors +also clear the guard. Errors do not automatically drive subsequent frames or +drain the turn queue. + +No battle, covered menus, mismatched phases, finished battles, fainted/NPC +choosers, automatic locks, stale targets, demos/tutorials, link/spectator, +Palace and Arena controllers are refused. This API does not advance text, +drive a covered party/bag/choice screen, perform a forced replacement, or +bypass a script/controller. Ordinary keyboard/touch input continues through +the same extracted native dispatch methods and retains its existing messages, +queueing, RNG, targeting and per-version behavior. diff --git a/docs/battle-snapshot-api.md b/docs/battle-snapshot-api.md index 2e9266704..e6e1cfa3c 100644 --- a/docs/battle-snapshot-api.md +++ b/docs/battle-snapshot-api.md @@ -18,7 +18,9 @@ bootstrapping Game. Outside battle it returns `nil, "no battle"`. A UI screen me If the capability-query extension is installed, `mod:supports("battle", "snapshot")` returns true without constructing Game. -`submit` and other battle capabilities return false with a reason. +When the [battle action extension](battle-actions-api.md) is installed, +`mod:supports("battle", "submit")` also returns true. Other battle capabilities +return false with a reason. ## Records @@ -103,9 +105,14 @@ pre-advance delay. Party, bag and choice screens above battle stay locked; this API does not inspect or drive those screens. Link controllers remain locked. Visible messages decode the native revealed glyph buffer through the imported charmap and already-loaded Unicode glyph map; this never initializes a font. A prompt is descriptive, -not an action-permission token. There is no battle input/submission API. +not an action-permission token. The [battle action extension](battle-actions-api.md) supplies separately validated +submission; the snapshot's descriptive prompt is not its permission check. -Identical successive snapshot records retain their revision. A change to +Identical successive snapshot records and action contexts retain their revision. +The action extension also tracks native controller/selector identity and private +control flags, so replacement objects can invalidate an otherwise identical +record. Accepted public actions invalidate all facades on that Game, including +snapshots observed during native callbacks. A change to any exposed data advances it once at the next read; animation clocks and interpolated HP alone do not. A different battle or Game also advances it, even with identical records. Observing `no battle` invalidates the previous diff --git a/docs/modding.md b/docs/modding.md index dbbfadc75..023f89f5d 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -158,7 +158,9 @@ frame"; the engine draws the vanilla world instead. including singles and doubles, or `nil, "no battle"`. It can be captured in an entry chunk before Game exists. See [Battle snapshot API](battle-snapshot-api.md) for records, revisions, prompt semantics and intentionally omitted previews. -It reads state only; there is no `submit` method. When capability queries are +The snapshot reads state only. The [Battle action API](battle-actions-api.md) +adds validated `mod.battle:submit(intent)` for the native menu, move, target, +back, Safari and Mimic paths. When capability queries are installed, `mod:supports("battle", "snapshot")` reports this interface. ## Battle sprite scaling diff --git a/src/battle/BattleCommands.lua b/src/battle/BattleCommands.lua new file mode 100644 index 000000000..9c0778a86 --- /dev/null +++ b/src/battle/BattleCommands.lua @@ -0,0 +1,355 @@ +-- Native command dispatch shared by BattleState input and mod.battle intents. +-- Keep gameplay mutations here, after the mod facade's admission checks. +local Strings = require("src.core.Strings") +local Targeting = require("src.battle.Targeting") +local Commands, Methods = {}, {} +Commands.methods = Methods + +function Methods.chooseMenu(self, choice) + -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / + -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the + -- third slot IS the ball, so the item action becomes the throw. + if choice == "item" and self.bugContest then + self:bugContestBall() + return true + end + if choice == "fight" and self.ghost then + self:say(Strings("%s is too\nscared to move!", self.player.name)) + self.phase = "messages" + self.afterQueue = "menu" + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + self:act(function() + self:endOfTurn() + end) + elseif choice == "fight" then + -- After the menu: own trapping/Bide or foe Wrap skips the move + -- list and forces the locked action (core.asm:320-329) + local fightLock = self:fightLockedAction(self.player) + if fightLock then + self:resolveTurn(fightLock) + return true + end + local who = self:menuBattler() + if not self:playerHasPP(who) then + -- _NoMovesLeftText, then Struggle engages + self:say(Strings("%s has no\nmoves left!", who.name)) + self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) + return true + end + self.phase = "moveSelect" + self.moveIndex = math.min(self.moveIndex, #who.curMoves) + self.moveSwapIndex = nil + elseif choice == "run" then + self:tryRun() + elseif choice == "item" then + self:openItems() + else + self:openParty() + end + return true +end + +function Methods.chooseMove(self, index) + local chooser = self:menuBattler() + local moves = chooser.curMoves + self.moveIndex = index + local mv = moves[self.moveIndex] + local mvDef = mv and self.data.moves[mv.id] + if chooser.disabledSlot == self.moveIndex then + self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) + self.phase = "messages" + self.afterQueue = "menu" + -- TAUNT and TORMENT refuse the move at SELECTION rather than after the + -- fact, the way DISABLE already does, so the player is never told to + -- pick again for something the game was never going to allow. + elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then + -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block + self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tauntBlocks(chooser, mvDef) then + self:say(Strings("%s can't use\n%s after the\ntaunt!", chooser.name, mvDef and mvDef.name or mv.id)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tormentBlocks(chooser, mvDef) then + self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", chooser.name)) + self.phase = "messages" + self.afterQueue = "menu" + elseif mv.pp <= 0 then + self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) + self.phase = "messages" + self.afterQueue = "menu" + elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then + -- WHICH OF THE TWO, when the move takes one and there are two. + -- + -- Emerald's selector cycles over the legal slots and names the + -- candidate; nothing else in the menu changes. A single battle + -- never gets here, because needsChoice answers false whenever there + -- is only one thing to aim at -- and on the Game Boy cartridges the + -- moves carry no target byte at all, so it answers false for every + -- move in the game. + self.pendingMove = mv + self.targetChoices = Targeting.choices(self, self:menuBattler()) + self.targetIndex = 1 + self.phase = "targetSelect" + else + self:chooseAction(mv) + end + return true +end + +function Methods.chooseTarget(self, index) + local list = self.targetChoices or {} + self.targetIndex = index + local mv = self.pendingMove + local pick = list[self.targetIndex] + self.pendingMove, self.targetChoices = nil, nil + self.chosenTargets = self.chosenTargets or {} + local who = self:menuBattler() + if who and who.position then + self.chosenTargets[who.position] = pick + end + self:chooseAction(mv) + return true +end + +function Methods.chooseMimic(self, index) + local moves = self.mimicMoves + self.mimicIndex = index + local pick = moves[self.mimicIndex] + local ctx = self.mimicCtx + self.mimicMoves, self.mimicCtx = nil, nil + self.phase = "messages" + self.nextInsert = 0 -- the copy's anim + text go to the queue head + self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + return true +end + +local SAFARI_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 } +function Methods.chooseSafari(self, action) + self.menuIndex = SAFARI_INDEX[action] + self:safariAction(action) + return true +end + +local function backToFirst(self) + if not self:isDouble() or self:choosingSlotNow() ~= 2 then + return nil + end + local left = self:battlerAt(0) + local pending = self.pendingActions and self.pendingActions[0] + if + left + and not left.ally + and (left.mon and left.mon.hp or 0) > 0 + and pending + and not self:menuLockedAction(left) + then + return left + end +end +function Methods.cancelMove(self) + if self.phase == "moveSelect" then + self.moveSwapIndex, self.phase = nil, "menu" + elseif self.phase == "targetSelect" then + self.pendingMove, self.targetChoices = nil, nil + self.phase = "moveSelect" + elseif self.phase == "menu" and backToFirst(self) then + self.pendingActions[0] = nil + if self.chosenTargets then + self.chosenTargets[0] = nil + end + self.choosingSlot, self.menuIndex, self.moveIndex = 0, 1, 1 + pcall(function() + require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") + end) + else + return nil, "nothing to cancel" + end + return true +end + +local function moveReason(b, actor, slot, move) + if not move or not b.data.moves[move.id] then + return "invalid move slot" + end + if actor.disabledSlot == slot then + return "move is disabled" + end + if (move.pp or 0) <= 0 then + return "move has no PP" + end + local def = b.data.moves[move.id] + if b.bw and require("src.battle.Gen5MoveRules").moveBlocked(b, actor, def, true) then + return "move is blocked by native BW rules" + end + if b:tauntBlocks(actor, def) then + return "move is blocked by Taunt" + end + if b:tormentBlocks(actor, def) then + return "move is blocked by Torment" + end +end + +-- Admission checks are read-only. Native refusal messages still belong to +-- chooseMove when driven by ordinary input; invalid API requests do not act. +function Commands.reason(b, intent) + if b.kind == "link" or b.spectating or b.demo or b.dudeDemo or b.pokedudeDemo or b.palace or b.arena then + return "battle controller owns input" + end + if b.result or b.dead or b.waitingUI then + return "battle is not ready" + end + if intent.kind == "safari" then + if b.phase ~= "menu" or not b.safari then + return "safari menu is not active" + end + if (b.safari.balls or 0) <= 0 then + return "no safari balls remain" + end + return + end + if b.safari then + return "safari controller owns input" + end + local actor = intent.kind == "mimic" and b.mimicCtx and b.mimicCtx.user or b:menuBattler() + if not (actor and actor.mon) or actor.ally or (actor.mon.hp or 0) <= 0 then + return "player battler is not ready" + end + if + intent.kind ~= "mimic" + and ( + b:menuLockedAction(actor) + or actor.mustRecharge + or actor.charging + or actor.rageMove + or (actor.thrashTurns and actor.thrashTurns > 0) + ) + then + return "player battler is locked" + end + if intent.kind == "menu" then + if b.phase ~= "menu" then + return "battle menu is not active" + end + local acting = b.player or b:activeOn(1) + if not acting or (acting.mon.hp or 0) <= 0 or b:menuLockedAction(acting) then + return "battle menu is not ready" + end + elseif intent.kind == "move" then + if b.phase ~= "moveSelect" then + return "move menu is not active" + end + if b.moveSwapIndex then + return "move reorder is active" + end + return moveReason(b, actor, intent.slot, (actor.curMoves or {})[intent.slot]) + elseif intent.kind == "target" then + if b.phase ~= "targetSelect" then + return "target menu is not active" + end + local move, slot = b.pendingMove, nil + for index, mv in ipairs(actor.curMoves or {}) do + if mv == move then + slot = index + break + end + end + if not slot then + return "pending move is no longer current" + end + local reason = moveReason(b, actor, slot, move) + if reason then + return reason + end + if not Targeting.needsChoice(b, actor, b.data.moves[move.id]) then + return "target choice is no longer needed" + end + local pick = b:battlerAt(intent.target) + if not (pick and pick.mon and (pick.mon.hp or 0) > 0) then + return "target is not on the field" + end + local offered = false + for _, target in ipairs(b.targetChoices or {}) do + if target == pick then + offered = true + break + end + end + if not offered then + return "target is not in the current selector" + end + for _, target in ipairs(Targeting.choices(b, actor)) do + if target == pick then + return + end + end + return "target is no longer eligible" + elseif intent.kind == "back" then + if b.phase == "moveSelect" or b.phase == "targetSelect" or (b.phase == "menu" and backToFirst(b)) then + return + end + return "nothing to cancel" + elseif intent.kind == "mimic" then + if b.phase ~= "mimicSelect" then + return "mimic menu is not active" + end + local ctx, pick = b.mimicCtx, (b.mimicMoves or {})[intent.index] + if not ctx or not pick or ctx.user ~= actor or not ctx.target or not ctx.moveInst then + return "invalid mimic context" + end + local targetPresent = false + for pos = 0, 3 do + if b:battlerAt(pos) == ctx.target then + targetPresent = true + end + end + if not targetPresent or #(actor.curMoves or {}) == 0 then + return "invalid mimic context" + end + local onField = false + for _, pos in ipairs({ 0, 2 }) do + if b:battlerAt(pos) == actor then + onField = true + end + end + if not onField then + return "mimic user is no longer on the field" + end + local source = (ctx.target.curMoves or {})[pick.slot] + if not source or source.id ~= pick.id then + return "mimic option is no longer current" + end + else + return "unknown battle intent" + end +end + +local DISPATCH = { + menu = "chooseMenu", + move = "chooseMove", + target = "chooseTarget", + back = "cancelMove", + safari = "chooseSafari", + mimic = "chooseMimic", +} +function Commands.dispatch(b, intent) + -- Ordinary input already performs this at its menu boundary. + if b.phase == "menu" and not b.safari then + b:clearTurnFlinches() + end + if intent.kind == "target" then + local pick = b:battlerAt(intent.target) + for index, target in ipairs(b.targetChoices or {}) do + if target == pick then + return b:chooseTarget(index) + end + end + return nil, "target is not in the current selector" + end + local value = intent.choice or intent.slot or intent.action or intent.index + return b[DISPATCH[intent.kind]](b, value) +end +return Commands diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c72bad08e..c2f51594a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -38,6 +38,7 @@ local WideBattle = require("src.battle.WideBattle") local Gen3Battle = require("src.battle.Gen3Battle") local Gen4Battle = require("src.battle.Gen4Battle") local GameVersion = require("src.core.GameVersion") +local BattleCommands = require("src.battle.BattleCommands") -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -55,6 +56,8 @@ BattleState.__index = BattleState BattleState.isOpaque = true -- Data readers can identify the shared Gen 1-5 battle without loading this module. BattleState.isBattleState = true +-- Input and public intents share the exact same native command dispatch. +for name, method in pairs(BattleCommands.methods) do BattleState[name] = method end -- pokered prints the battle lines itself (engine/battle/core.asm and the -- move-effect banks), and the importer extracts every one of them, so the @@ -4408,7 +4411,7 @@ function BattleState:update(dt) Gen4Battle.actionMove(self.menuIndex, dir, self.gen4MenuColumn) end if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4434,7 +4437,7 @@ function BattleState:update(dt) end self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4514,60 +4517,10 @@ function BattleState:update(dt) -- asked (fainted, or locked into a move it cannot choose). if input:wasPressed("b") and self:isDouble() and self:choosingSlotNow() == BattleState.POS.PLAYER_RIGHT then - local left = self:battlerAt(BattleState.POS.PLAYER_LEFT) - local pend = self.pendingActions and self.pendingActions[BattleState.POS.PLAYER_LEFT] - if left and not left.ally and (left.mon and left.mon.hp or 0) > 0 and pend - and not self:menuLockedAction(left) then - self.pendingActions[BattleState.POS.PLAYER_LEFT] = nil - if self.chosenTargets then self.chosenTargets[BattleState.POS.PLAYER_LEFT] = nil end - self.choosingSlot = BattleState.POS.PLAYER_LEFT - self.menuIndex, self.moveIndex = 1, 1 - pcall(function() require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") end) - return - end + if self:cancelMove() then return end end if input:wasPressed("a") then - local choice = self:menuActions()[self.menuIndex] - -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / - -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the - -- third slot IS the ball, so the item action becomes the throw. - if choice == "item" and self.bugContest then - self:bugContestBall() - return - end - if choice == "fight" and self.ghost then - self:say(Strings("%s is too\nscared to move!", self.player.name)) - self.phase = "messages" - self.afterQueue = "menu" - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) - self:act(function() self:endOfTurn() end) - elseif choice == "fight" then - -- After the menu: own trapping/Bide or foe Wrap skips the move - -- list and forces the locked action (core.asm:320-329) - local fightLock = self:fightLockedAction(self.player) - if fightLock then - self:resolveTurn(fightLock) - return - end - local who = self:menuBattler() - if not self:playerHasPP(who) then - -- _NoMovesLeftText, then Struggle engages - self:say(Strings("%s has no\nmoves left!", who.name)) - self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) - return - end - self.phase = "moveSelect" - self.moveIndex = math.min(self.moveIndex, #who.curMoves) - self.moveSwapIndex = nil - elseif choice == "run" then - self:tryRun() - elseif choice == "item" then - self:openItems() - else - self:openParty() - end + self:chooseMenu(self:menuActions()[self.menuIndex]) end return end @@ -4593,58 +4546,14 @@ function BattleState:update(dt) self.moveSwapIndex = self.moveIndex end elseif input:wasPressed("b") then - self.moveSwapIndex = nil - self.phase = "menu" + self:cancelMove() elseif input:wasPressed("a") then if self.moveSwapIndex then self:swapMoves(self.moveSwapIndex, self.moveIndex) self.moveSwapIndex = nil return end - local mv = moves[self.moveIndex] - local mvDef = mv and self.data.moves[mv.id] - if chooser.disabledSlot == self.moveIndex then - self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) - self.phase = "messages" - self.afterQueue = "menu" - -- TAUNT and TORMENT refuse the move at SELECTION rather than after the - -- fact, the way DISABLE already does, so the player is never told to - -- pick again for something the game was never going to allow. - elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then - -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block - self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tauntBlocks(chooser, mvDef) then - self:say(Strings("%s can't use\n%s after the\ntaunt!", - chooser.name, mvDef and mvDef.name or mv.id)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tormentBlocks(chooser, mvDef) then - self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", - chooser.name)) - self.phase = "messages" - self.afterQueue = "menu" - elseif mv.pp <= 0 then - self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) - self.phase = "messages" - self.afterQueue = "menu" - elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then - -- WHICH OF THE TWO, when the move takes one and there are two. - -- - -- Emerald's selector cycles over the legal slots and names the - -- candidate; nothing else in the menu changes. A single battle - -- never gets here, because needsChoice answers false whenever there - -- is only one thing to aim at -- and on the Game Boy cartridges the - -- moves carry no target byte at all, so it answers false for every - -- move in the game. - self.pendingMove = mv - self.targetChoices = Targeting.choices(self, self:menuBattler()) - self.targetIndex = 1 - self.phase = "targetSelect" - else - self:chooseAction(mv) - end + self:chooseMove(self.moveIndex) end return end @@ -4662,16 +4571,9 @@ function BattleState:update(dt) elseif input:wasPressed("right") or input:wasPressed("down") then self.targetIndex = self.targetIndex < #list and self.targetIndex + 1 or 1 elseif input:wasPressed("b") then - self.pendingMove, self.targetChoices = nil, nil - self.phase = "moveSelect" + self:cancelMove() elseif input:wasPressed("a") then - local mv = self.pendingMove - local pick = list[self.targetIndex] - self.pendingMove, self.targetChoices = nil, nil - self.chosenTargets = self.chosenTargets or {} - local who = self:menuBattler() - if who and who.position then self.chosenTargets[who.position] = pick end - self:chooseAction(mv) + self:chooseTarget(self.targetIndex) end return end @@ -4692,12 +4594,7 @@ function BattleState:update(dt) elseif input:wasPressed("down") then self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1 elseif input:wasPressed("a") then - local pick = moves[self.mimicIndex] - local ctx = self.mimicCtx - self.mimicMoves, self.mimicCtx = nil, nil - self.phase = "messages" - self.nextInsert = 0 -- the copy's anim + text go to the queue head - self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + self:chooseMimic(self.mimicIndex) end return end diff --git a/src/mods/BattleIntents.lua b/src/mods/BattleIntents.lua new file mode 100644 index 000000000..5daa69311 --- /dev/null +++ b/src/mods/BattleIntents.lua @@ -0,0 +1,178 @@ +-- Data-only intent validation and private cross-facade submission state. +local Intents = {} +local states = setmetatable({}, { __mode = "k" }) +local FIELDS = { + menu = { choice = true }, + move = { slot = true }, + target = { target = true }, + back = {}, + safari = { action = true }, + mimic = { index = true }, +} +local MENU = { fight = true, party = true, item = true, run = true } +local SAFARI = { ball = true, bait = true, rock = true, run = true } +local function integer(v, lo, hi) + return type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi +end +function Intents.normalize(raw) + if type(raw) ~= "table" or getmetatable(raw) ~= nil then + return nil, "intent must be a plain table" + end + if not integer(raw.id, 1, 9007199254740991) then + return nil, "intent id must be a positive safe integer" + end + if not integer(raw.revision, 1, 9007199254740991) then + return nil, "invalid battle revision" + end + local fields = type(raw.kind) == "string" and FIELDS[raw.kind] + if not fields then + return nil, "unknown battle intent" + end + for key in pairs(raw) do + if key ~= "id" and key ~= "revision" and key ~= "kind" and not fields[key] then + return nil, "unknown battle intent field" + end + end + local out = { id = raw.id, revision = raw.revision, kind = raw.kind } + if raw.kind == "menu" then + if type(raw.choice) ~= "string" or not MENU[raw.choice] then + return nil, "unknown battle menu choice" + end + out.choice = raw.choice + elseif raw.kind == "move" then + if not integer(raw.slot, 1, 4) then + return nil, "invalid move slot" + end + out.slot = raw.slot + elseif raw.kind == "target" then + if not integer(raw.target, 0, 3) then + return nil, "target must be a battler id 0-3" + end + out.target = raw.target + elseif raw.kind == "safari" then + if type(raw.action) ~= "string" or not SAFARI[raw.action] then + return nil, "invalid safari action" + end + out.action = raw.action + elseif raw.kind == "mimic" then + if not integer(raw.index, 1, 4) then + return nil, "invalid mimic index" + end + out.index = raw.index + end + return out +end +function Intents.state(game) + local state = states[game] + if not state then + state = { epoch = 0, busy = false } + states[game] = state + end + return state +end + +local BATTLE_FLAGS = { + "waitingUI", + "dead", + "demo", + "dudeDemo", + "pokedudeDemo", + "palace", + "arena", + "spectating", + "bugContest", + "noItems", + "gen4Safari", +} +local BATTLER_FLAGS = { + "disabledSlot", + "tauntTurns", + "tormented", + "lastMove", + "mustRecharge", + "charging", + "thrashTurns", + "thrashMove", + "rageMove", + "trappingTurns", + "bideTurns", + "encoreTurns", + "encoreMove", + "bwSkyDropped", + "bwChoiceLock", + "bwImprison", + "bwHealBlock", + "bwEmbargo", +} +-- Reference identity matters as well as record values: an identical replacement +-- Pokemon, move slot, selector or covering screen must invalidate old commands. +-- refs are compared shallowly; values are copied by the snapshot owner. +function Intents.context(game, b, top, at) + local refs = { + game = game, + battle = b, + top = top, + stack = game.stack, + queue = b.queue, + current = b.current, + targets = b.targetChoices, + mimic = b.mimicCtx, + mimicMoves = b.mimicMoves, + pendingMove = b.pendingMove, + pending = b.pendingActions, + chosen = b.chosenTargets, + safari = b.safari, + } + local values = { queueLength = #(b.queue or {}), flags = {}, battlers = {} } + for _, key in ipairs(BATTLE_FLAGS) do + values.flags[key] = type(b[key]) == "table" and true or b[key] + end + values.bw = type(b.bw) == "table" + and { gravity = b.bw.field and b.bw.field.gravity, magicRoom = b.bw.field and b.bw.field.magicRoom } + or b.bw + if b.mimicCtx then + refs.mimicUser, refs.mimicTarget, refs.mimicMove = b.mimicCtx.user, b.mimicCtx.target, b.mimicCtx.moveInst + end + for i, move in ipairs(b.mimicMoves or {}) do + refs["mimicOption" .. i] = move + end + for pos = 0, 3 do + local actor = at(b, pos) + refs["b" .. pos] = actor + if actor then + refs["mon" .. pos] = actor.mon + refs["moves" .. pos] = actor.curMoves + local flags = {} + for _, key in ipairs(BATTLER_FLAGS) do + flags[key] = type(actor[key]) == "table" and true or actor[key] + end + values.battlers[pos] = flags + for slot, mv in ipairs(actor.curMoves or {}) do + refs["move" .. pos .. ":" .. slot] = mv + end + end + refs["pending" .. pos] = b.pendingActions and b.pendingActions[pos] + refs["chosen" .. pos] = b.chosenTargets and b.chosenTargets[pos] + end + for i, target in ipairs(b.targetChoices or {}) do + refs["target" .. i] = target + end + return refs, values +end +function Intents.sameRefs(a, b) + if not a or not b then + return a == b + end + for key, value in pairs(a) do + if b[key] ~= value then + return false + end + end + for key, value in pairs(b) do + if a[key] ~= value then + return false + end + end + return true +end +return Intents diff --git a/src/mods/BattleSnapshots.lua b/src/mods/BattleSnapshots.lua index 79a0aa417..1fce0a816 100644 --- a/src/mods/BattleSnapshots.lua +++ b/src/mods/BattleSnapshots.lua @@ -1,6 +1,19 @@ --- A data-only view of the shared Gen 1-5 battle. Never call battle/menu, --- item-effect or preview helpers here: some of those consume RNG or write state. +-- Detached Gen 1-5 snapshots and revision-bound native command admission. +-- Snapshot building stays data-only; submit validates before native dispatch. local Ids = require("src.mods.UnifiedIds") +local Intents = require("src.mods.BattleIntents") +local Commands = require("src.battle.BattleCommands") +local Runtime = require("src.mods.Runtime") +-- Native work requested through a facade remains engine work. The buses keep +-- their existing callback/error attribution; lazy requires must not be blamed +-- on the requesting mod by the dev-mode permission shim. +local function enginePcall(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local ok, a, b = pcall(fn, ...) + Runtime.currentMod = owner + return ok, a, b +end local Snapshots = {} local STATUS = @@ -86,6 +99,13 @@ local function at(battle, pos) end local function activeSlot(battle) + if battle.phase == "mimicSelect" and battle.mimicCtx then + for _, pos in ipairs({ 0, 2 }) do + if at(battle, pos) == battle.mimicCtx.user then + return pos + end + end + end if not battle.double then return 0 end @@ -427,7 +447,8 @@ function Snapshots.new(gameProvider, loader) end local facade = {} local lastGame, lastBattle, previous, revision = nil, nil, nil, 0 - function facade:snapshot() + local lastRefs, lastControl, lastEpoch, offeredRevision, lastIntentId = nil, nil, nil, nil, 0 + local function refresh() local game = gameProvider() local states = game and game.stack and game.stack.states or {} local battle, index @@ -438,18 +459,108 @@ function Snapshots.new(gameProvider, loader) end end if not battle then - lastGame, lastBattle, previous = nil, nil, nil - return nil, "no battle" + lastGame, lastBattle, previous, lastRefs, lastControl, offeredRevision = nil, nil, nil, nil, nil, nil + return nil, nil, nil, "no battle" end + local state = Intents.state(game) local out = copy(build(game, battle, index ~= #states, Ids.new(loader))) - if game ~= lastGame or battle ~= lastBattle or not equal(out, previous) then + local refs, control = Intents.context(game, battle, states[#states], at) + control = copy(control) + if + game ~= lastGame + or battle ~= lastBattle + or not equal(out, previous) + or not Intents.sameRefs(refs, lastRefs) + or not equal(control, lastControl) + or state.epoch ~= lastEpoch + then revision = revision + 1 end lastGame, lastBattle, previous = game, battle, out - local result = copy(out) + lastRefs, lastControl, lastEpoch = refs, control, state.epoch + return game, battle, index, state + end + function facade:snapshot() + local game, battle, _, state = refresh() + if not battle then + return nil, "no battle" + end + local result = copy(previous) result.revision = revision + if state.busy then + result.prompt = "locked" + end + offeredRevision = revision return result end + function facade:submit(raw) + local intent, reason = Intents.normalize(raw) + if not intent then + return nil, reason + end + if intent.id <= lastIntentId then + return nil, "replayed intent" + end + local game, battle, index, state = refresh() + if not battle then + return nil, "no battle" + end + if state.busy then + return nil, "battle action is already in progress" + end + if intent.revision ~= revision then + return nil, "stale battle context" + end + if offeredRevision ~= revision then + return nil, "fresh battle snapshot required" + end + if battle.game ~= game then + return nil, "battle does not belong to current Game" + end + if index ~= #game.stack.states then + return nil, "battle menu is covered" + end + -- The native admission helpers are read-only. Lock during validation too, + -- so a mod callback/overridden helper cannot recursively enter submission. + local refs, control = lastRefs, lastControl + state.busy = true + local checked, refusal = enginePcall(Commands.reason, battle, intent) + if not checked or refusal then + state.busy = false + return nil, checked and refusal or "battle validation failed" + end + local fresh, currentGame, currentBattle = pcall(refresh) + if not fresh then + state.busy = false + return nil, "battle validation failed" + end + if + game ~= currentGame + or battle ~= currentBattle + or revision ~= intent.revision + or not Intents.sameRefs(refs, lastRefs) + or not equal(control, lastControl) + then + state.busy = false + return nil, "stale battle context" + end + -- Consume BEFORE native code: a callback or partial native failure cannot + -- replay this ID. Both boundaries invalidate snapshots held by other mods, + -- including snapshots observed halfway through an accepted action. + lastIntentId, offeredRevision = intent.id, nil + state.epoch = state.epoch + 1 + local ok, accepted, err = enginePcall(Commands.dispatch, battle, intent) + state.epoch = state.epoch + 1 + state.busy = false + if not ok then + local printable, message = pcall(tostring, accepted) + return nil, "battle action failed; intent consumed" .. (printable and ": " .. message or "") + end + if not accepted then + return nil, err or "battle action did not complete; intent consumed" + end + return true + end return facade end -- Run after construction so an optional capability provider and its other @@ -459,7 +570,7 @@ function Snapshots.withCapabilities(api) local supports = api.supports function api:supports(surface, name) if surface == "battle" then - if name == "snapshot" then + if name == "snapshot" or name == "submit" then return true end return false, "unknown battle capability: " .. tostring(name) diff --git a/tests/engine/battle_actions.lua b/tests/engine/battle_actions.lua new file mode 100644 index 000000000..2c6799081 --- /dev/null +++ b/tests/engine/battle_actions.lua @@ -0,0 +1,758 @@ +-- Public intents exercised through real Loader, BattleState and native input. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local BattleState = require("src.battle.BattleState") +local GameVersion = require("src.core.GameVersion") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local Merge = require("src.mods.Merge") +local Commands = require("src.battle.BattleCommands") +local Intents = require("src.mods.BattleIntents") +local oldVersion = GameVersion.get() + +local function boot(version, trainer, level) + GameVersion.set(version) + math.randomseed(12345) + local data = T.fixtures.fresh() + local files = {} + for _, id in ipairs({ "probe", "other" }) do + files["mods/" .. id .. "/manifest.json"] = + Json.encode({ id = id, name = id, version = "1.0.0", api = level or 2, entry = "main.lua" }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + assert(type(mod.battle.submit)=="function") + assert(mod.battle:snapshot()==nil) + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local game = { data = data, stack = { states = {} }, save = SaveData.newGame() } + loader.game = game + assert(loader:load(data), table.concat(loader.errors, "; ")) + Font.load(data) + game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + game.stack.top = function(stack) + return stack.states[#stack.states] + end + game.stack.push = function(stack, state) + stack.states[#stack.states + 1] = state + end + game.stack.pop = function(stack) + return table.remove(stack.states) + end + local b = trainer and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + or BattleState.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase = "menu" + b.gen4CursorActive = true + b.frame = 0 + b.queue = {} + b.menuIndex = 1 + b.moveIndex = 1 + local rolls = 0 + b.rng = function(lo, hi) + rolls = rolls + 1 + return lo + end + game.stack.states = { b } + return loader.apis.probe.battle, + game, + b, + loader.apis.other.battle, + loader, + function() + return rolls + end +end +local function intent(api, id, kind, fields) + local s = assert(api:snapshot()) + local out = { id = id, revision = s.revision, kind = kind } + for k, v in pairs(fields or {}) do + out[k] = v + end + return out +end +local function send(api, id, kind, fields) + return api:submit(intent(api, id, kind, fields)) +end +local function freeze(root) + local graph = {} + local function visit(value) + if type(value) ~= "table" or graph[value] then + return + end + local row = { values = {}, mt = getmetatable(value) } + graph[value] = row + for key, item in pairs(value) do + row.values[key] = item + visit(item) + end + end + visit(root) + return function() + for tbl, row in pairs(graph) do + if getmetatable(tbl) ~= row.mt then + return false + end + for key, value in pairs(row.values) do + if tbl[key] ~= value then + return false + end + end + for key in pairs(tbl) do + if row.values[key] == nil then + return false + end + end + end + return true + end +end +local function forbidden() + error("unexpected native side effect") +end +local function reject(api, game, raw, fragment, label) + local battler = game.stack.states[1] + local oldRng = battler and battler.rng + if battler and battler.isBattleState then + battler.rng = forbidden + end + local unchanged = freeze(game) + local oldLua, oldLove, oldEmit, oldCall = math.random, love.math.random, Runtime.emit, Runtime.call + math.random, love.math.random, Runtime.emit, Runtime.call = forbidden, forbidden, forbidden, forbidden + local ok, reason = api:submit(raw) + math.random, love.math.random, Runtime.emit, Runtime.call = oldLua, oldLove, oldEmit, oldCall + T.eq(ok, nil, label .. ": rejected") + T.check( + type(reason) == "string" and reason:find(fragment, 1, true) ~= nil, + label .. ": reason " .. tostring(reason) + ) + T.check(unchanged(), label .. ": whole game graph unchanged") + if battler and battler.isBattleState then + battler.rng = oldRng + end +end +local function view(api) + local s = assert(api:snapshot()) + s.revision = nil + return s +end +local function press(game, b, key) + game.input.wasPressed = function(_, name) + return name == key + end + b:update(1 / 60) + game.input.wasPressed = function() + return false + end +end + +for _, version in ipairs(GameVersion.ORDER) do + local label = version .. ": " + -- Identical native input and API commands, including actual turn planning. + local api, game, b, other, loader, rolls = boot(version) + local control, cgame, cb, _, _, crolls = boot(version) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "FIGHT accepted") + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "FIGHT matches native A dispatch") + T.eq(send(api, 2, "move", { slot = 1 }), true, label .. "move accepted") + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "move matches native A dispatch") + T.eq(rolls(), crolls(), label .. "native RNG consumption matches") + T.eq(#b.queue, #cb.queue, label .. "native action queue matches") + T.eq(b.turnCount, 1, label .. "exactly one native turn planned") + local function drain(g, bb) + g.input.wasPressed = function() + return true + end + g.input.isDown = function() + return true + end + for frame = 1, 5000 do + if not bb:updateQueue() then + return true + end + end + return false + end + T.check(drain(game, b), label .. "API native turn queue completes") + T.check(drain(cgame, cb), label .. "input native turn queue completes") + T.eq(b.enemy.mon.hp, cb.enemy.mon.hp, label .. "native damage identical after queue") + T.eq(b.player.mon.hp, cb.player.mon.hp, label .. "enemy retaliation identical after queue") + T.eq(b.player.curMoves[1].pp, cb.player.curMoves[1].pp, label .. "PP consumption identical after queue") + T.eq(rolls(), crolls(), label .. "full native turn RNG identical") + if loader.apis.probe.supports then + T.eq(loader.apis.probe:supports("battle", "submit"), true, label .. "submit capability") + end + -- Menu opening does not bypass the party/bag controller or consume items. + for _, choice in ipairs({ "party", "item", "run" }) do + local a, g, bb = boot(version) + local c, cg, cc = boot(version) + T.eq(send(a, 1, "menu", { choice = choice }), true, label .. choice .. " accepted") + local indices = cc:menuActions() + for i, value in ipairs(indices) do + if value == choice or (choice == "party" and value == "pkmn") then + cc.menuIndex = i + end + end + press(cg, cc, "a") + -- Native cursor is selected by the player; the public API addresses the + -- action by name, so it need not move that display cursor. + local sv, cv = view(a), view(c) + sv.menuIndex = nil + cv.menuIndex = nil + T.same(sv, cv, label .. choice .. " matches native input") + T.eq(#bb.queue, #cc.queue, label .. choice .. " native queue") + end + api, game, b = boot(version) + local first = assert(api:snapshot()) + local bads = { false, {}, setmetatable({ id = 1, revision = first.revision, kind = "back" }, {}) } + for _, raw in ipairs(bads) do + reject(api, game, raw, "", label .. "malformed intent") + end + for _, value in ipairs({ 0, -1, 1.5, math.huge, -math.huge, 0 / 0, "1", true, 9007199254740992 }) do + reject( + api, + game, + { id = value, revision = first.revision, kind = "menu", choice = "fight" }, + "integer", + label .. "unsafe ID" + ) + reject( + api, + game, + { id = 1, revision = value, kind = "menu", choice = "fight" }, + "revision", + label .. "unsafe revision" + ) + end + local malformed = { + { kind = "mystery" }, + { kind = "menu", choice = "catch" }, + { kind = "menu", choice = {} }, + { kind = "move", slot = "1" }, + { kind = "move", slot = 0 }, + { kind = "move", slot = 5 }, + { kind = "target", target = -1 }, + { kind = "target", target = 4 }, + { kind = "mimic", index = 0 }, + { kind = "mimic", index = 5 }, + { kind = "safari", action = "mud" }, + { kind = "safari", action = false }, + { kind = "back", extra = true }, + { kind = "move", slot = 1, idOverride = 3 }, + } + for _, fields in ipairs(malformed) do + local raw = { id = 1, revision = first.revision } + for k, v in pairs(fields) do + raw[k] = v + end + reject(api, game, raw, "", label .. "strict intent fields") + end + local current = intent(api, 1, "menu", { choice = "fight" }) + T.eq(api:submit(current), true, label .. "rejected IDs were not consumed") + reject(api, game, current, "replayed", label .. "accepted ID cannot replay") + current.id = 0 + reject(api, game, current, "integer", label .. "ID zero is invalid") + current = intent(api, 3, "move", { slot = 1 }) + b.player.mon.hp = b.player.mon.hp - 1 + reject(api, game, current, "stale", label .. "changed HP invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + b.player.curMoves[1] = Merge.deepCopy(b.player.curMoves[1]) + reject(api, game, current, "stale", label .. "identical replacement move invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + b.player.mon = Merge.deepCopy(b.player.mon) + reject(api, game, current, "stale", label .. "identical replacement Pokemon invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + game.stack.states[2] = { battle = b } + reject(api, game, current, "stale", label .. "new covering screen invalidates revision") + reject( + api, + game, + intent(api, 3, "move", { slot = 1 }), + "covered", + label .. "fresh covered snapshot still cannot act" + ) + game.stack.states[2] = nil + for _, phase in ipairs({ "intro", "messages", "waitRemote", "finished" }) do + b.phase = phase + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "active", + label .. "wrong phase " .. phase + ) + end + b.phase = "menu" + for _, flag in ipairs({ "demo", "dudeDemo", "pokedudeDemo", "spectating", "palace", "arena" }) do + b[flag] = true + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "controller", + label .. "automatic controller " .. flag + ) + b[flag] = nil + end + for _, flag in ipairs({ "result", "dead", "waitingUI" }) do + b[flag] = true + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "not ready " .. flag) + b[flag] = nil + end + b.kind = "link" + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "controller", label .. "link refused") + b.kind = "wild" + for _, flag in ipairs({ "mustRecharge", "charging", "rageMove", "thrashTurns" }) do + b.player[flag] = 1 + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "locked", + label .. flag .. " automatic lock" + ) + b.player[flag] = nil + end + b.player.ally = true + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "NPC ally refused") + b.player.ally = nil + b.player.mon.hp = 0 + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "fainted chooser refused") + b.player.mon.hp = 30 + b.phase = "moveSelect" + for _, guard in ipairs({ "pp", "disable", "taunt", "torment", "swap" }) do + local mv = b.player.curMoves[1] + local def = game.data.moves[mv.id] + local power = def.power + if guard == "pp" then + mv.pp = 0 + end + if guard == "disable" then + b.player.disabledSlot = 1 + end + if guard == "taunt" then + b.player.tauntTurns = 2 + def.power = 0 + end + if guard == "torment" then + b.player.tormented = true + b.player.lastMove = mv.id + end + if guard == "swap" then + b.moveSwapIndex = 1 + end + reject(api, game, intent(api, 3, "move", { slot = 1 }), "", label .. guard .. " rejected before action") + mv.pp = 20 + b.player.disabledSlot = nil + b.player.tauntTurns = nil + b.player.tormented = nil + b.player.lastMove = nil + b.moveSwapIndex = nil + def.power = power + end + reject(api, game, intent(api, 3, "move", { slot = 4 }), "slot", label .. "missing slot refused") + b.moveSwapIndex = 1 + T.eq(send(api, 3, "back"), true, label .. "back cancels reorder") + T.eq(b.moveSwapIndex, nil, label .. "reorder marker cleared") + T.eq(b.phase, "menu", label .. "back to menu") + reject(api, game, intent(api, 4, "back"), "nothing", label .. "single menu has no back action") + game.stack.states = {} + reject(api, game, { id = 4, revision = 1, kind = "back" }, "no battle", label .. "outside battle refused") +end +local function doubles(api, game, b) + math.randomseed(54321) + b.double = true + b:placeBattler(2, BattleState.makeBattler(game.data, game.save.party[2], true, game.save)) + local foe = Pokemon.new(game.data, "FIXMON_B", 10) + b.enemyParty = b.enemyParty or { b.enemy.mon } + b.enemyParty[2] = foe + b:placeBattler(3, BattleState.makeBattler(game.data, foe, false)) + b:syncSides() +end +for _, version in ipairs(GameVersion.ORDER) do + local label = version .. " doubles: " + local api, game, b = boot(version) + local control, cgame, cb = boot(version) + doubles(api, game, b) + doubles(control, cgame, cb) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "first FIGHT") + press(cgame, cb, "a") + T.eq(send(api, 2, "move", { slot = 1 }), true, label .. "first move enters targets") + press(cgame, cb, "a") + T.eq(b.phase, "targetSelect", label .. "native targeting phase") + reject(api, game, intent(api, 3, "target", { target = 0 }), "selector", label .. "cannot target own slot") + local offered = intent(api, 3, "target", { target = 3 }) + b.targetChoices = { unpack(b.targetChoices) } + reject(api, game, offered, "stale", label .. "selector identity changed") + T.eq(send(api, 3, "target", { target = 3 }), true, label .. "position 3 selected") + cb.targetIndex = 2 + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "position maps to correct native cursor") + T.eq(b.chosenTargets[0], b:battlerAt(3), label .. "chosen actual right foe") + T.eq(b.choosingSlot, 2, label .. "second chooser") + T.eq(send(api, 4, "back"), true, label .. "undo first selection from second menu") + press(cgame, cb, "b") + T.same(view(api), view(control), label .. "back matches native B") + T.eq(b.pendingActions[0], nil, label .. "pending first action cleared") + T.eq(b.chosenTargets[0], nil, label .. "pending target cleared") + -- Native B from target returns to moves, then from moves to action menu. + T.eq(send(api, 5, "menu", { choice = "fight" }), true, label .. "fight again") + T.eq(send(api, 6, "move", { slot = 1 }), true, label .. "target again") + T.eq(send(api, 7, "back"), true, label .. "cancel target") + T.eq(b.pendingMove, nil, label .. "pending move cleared") + T.eq(b.targetChoices, nil, label .. "target selector cleared") + T.eq(b.phase, "moveSelect", label .. "back to moves") + T.eq(send(api, 8, "back"), true, label .. "back to menu") + T.eq(send(api, 9, "menu", { choice = "fight" }), true, label .. "choose first action") + T.eq(send(api, 10, "move", { slot = 1 }), true, label .. "select first move") + local stale = intent(api, 11, "target", { target = 1 }) + b.pendingMove = Merge.deepCopy(b.pendingMove) + reject(api, game, stale, "stale", label .. "pending move identity changed") + reject(api, game, intent(api, 11, "target", { target = 1 }), "pending", label .. "orphaned pending move") + b.pendingMove = b.player.curMoves[1] + b.pendingMove.pp = 0 + reject(api, game, intent(api, 11, "target", { target = 1 }), "PP", label .. "pending move rechecks PP") + b.pendingMove.pp = 20 + b:battlerAt(1).mon.hp = 0 + reject(api, game, intent(api, 11, "target", { target = 1 }), "field", label .. "fainted target refused") + b:battlerAt(1).mon.hp = 30 + T.eq(send(api, 11, "target", { target = 1 }), true, label .. "first target") + T.eq(send(api, 12, "menu", { choice = "fight" }), true, label .. "second FIGHT") + T.eq(send(api, 13, "move", { slot = 1 }), true, label .. "second move") + T.eq(send(api, 14, "target", { target = 0 }), true, label .. "target position zero supported") + T.eq(b.chosenTargets[2], b.player, label .. "second actor aimed at left partner") + T.eq(b.turnCount, 1, label .. "second chooser resolves one whole turn") + -- A surviving right flank still owns a usable menu. + api, game, b = boot(version) + doubles(api, game, b) + b:placeBattler(0, nil) + T.eq(api:snapshot().active, 2, label .. "missing-left chooser") + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "right-only FIGHT") + T.eq(b.phase, "moveSelect", label .. "right-only moves") + -- Mimic belongs to its queued user, even when that is the right flank. + b.phase = "mimicSelect" + local user = b:battlerAt(2) + local target = b.enemy + b.mimicCtx = { user = user, target = target, moveInst = user.curMoves[1] } + b.mimicMoves = { { slot = 1, id = target.curMoves[1].id } } + b.mimicIndex = 1 + T.eq(api:snapshot().active, 2, label .. "Mimic uses queued right-flank user") + local picked = intent(api, 2, "mimic", { index = 1 }) + b.mimicCtx.target = b:battlerAt(3) + reject(api, game, picked, "stale", label .. "Mimic source identity changed") + b.mimicCtx.target = target + T.eq(send(api, 2, "mimic", { index = 1 }), true, label .. "native Mimic copy") + T.eq(user.curMoves[1].id, target.curMoves[1].id, label .. "Mimic installs selected source") + T.eq(b.phase, "messages", label .. "Mimic resumes queue") + T.eq(#b.mimicRestores, 1, label .. "native restoration bookkeeping preserved") +end + +-- Safari uses its native model, including Platinum's own action implementation. +for _, version in ipairs({ "red", "firered", "platinum", "heartgold" }) do + for _, action in ipairs({ "ball", "bait", "rock", "run" }) do + local api, game, b = boot(version) + local control, cgame, cb = boot(version) + for _, battle in ipairs({ b, cb }) do + battle.safari = { balls = 5 } + battle.safariCatchRate = 80 + battle.baitFactor = 0 + battle.escapeFactor = 0 + if version == "platinum" then + battle.gen4Safari = require("src.battle.Gen4Safari").init() + end + end + T.eq(send(api, 1, "safari", { action = action }), true, version .. ": Safari " .. action) + cb.menuIndex = ({ ball = 1, bait = 2, rock = 3, run = 4 })[action] + press(cgame, cb, "a") + T.same(view(api), view(control), version .. ": Safari matches native " .. action) + T.eq(b.safari.balls, action == "ball" and 4 or 5, version .. ": ball spent only for throw") + local old = intent(api, 2, "safari", { action = "ball" }) + reject(api, game, old, "", version .. ": cannot repeat Safari action during messages") + b.phase = "menu" + b.result = nil + b.safari.balls = 0 + reject( + api, + game, + intent(api, 2, "safari", { action = "run" }), + "balls", + version .. ": depleted Safari belongs to automatic exit" + ) + b.safari.balls = 1 + reject( + api, + game, + intent(api, 2, "menu", { choice = "fight" }), + "safari", + version .. ": regular menu cannot bypass Safari" + ) + end +end + +-- BW eligibility goes through the native read-only rules, not the snapshot's +-- deliberately partial disabled flag. +for _, version in ipairs({ "black", "white" }) do + for _, guard in ipairs({ "choice", "imprison", "gravity", "heal", "sky" }) do + local api, game, b = boot(version) + b.phase = "moveSelect" + b.bw = { field = {}, sides = { {}, {} } } + local mv = b.player.curMoves[1] + local def = game.data.moves[mv.id] + if guard == "choice" then + game.data.items.CHOICE = { name = "Choice", bwPocket = 1, holdEffect = 55 } + b.player.items = game.data.items + b.player.mon.item = "CHOICE" + b.player.bwChoiceLock = "FIX_CUT" + b.player.curMoves[2] = { id = "FIX_CUT", pp = 3 } + elseif guard == "imprison" then + b.enemy.bwImprison = true + b.enemy.curMoves = { { id = mv.id, pp = 2 } } + elseif guard == "gravity" then + b.bw.field.gravity = 3 + def.gravityBanned = true + elseif guard == "heal" then + b.player.bwHealBlock = 3 + def.healMove = true + else + b.player.bwSkyDropped = b.enemy + end + reject( + api, + game, + intent(api, 1, "move", { slot = 1 }), + "", + version .. ": native BW restriction " .. guard + ) + end +end + +-- Cross-facade atomicity, consumption and recovery are deliberately tested +-- with native boundary overrides; the real-input cases above verify gameplay. +do + local api, game, b, other = boot("crystal") + reject( + api, + game, + { id = 1, revision = 1, kind = "menu", choice = "fight" }, + "snapshot", + "unobserved context" + ) + local native = b.chooseMenu + local executions = 0 + local nested, mid + local otherRequest = intent(other, 1, "menu", { choice = "fight" }) + b.chooseMenu = function(self, choice) + executions = executions + 1 + nested = { other:submit(otherRequest) } + mid = other:snapshot() + return native(self, choice) + end + T.eq(send(api, 1, "menu", { choice = "fight" }), true, "outer command accepted") + T.eq(nested[1], nil, "reentrant other mod rejected") + T.check(nested[2]:find("progress", 1, true) ~= nil, "reentrant rejection reason") + T.eq(mid.prompt, "locked", "mid-dispatch snapshot is locked") + reject( + other, + game, + { id = 1, revision = mid.revision, kind = "back" }, + "stale", + "mid-dispatch revision cannot act after return" + ) + T.eq(executions, 1, "one native dispatch") + T.eq(send(other, 1, "back"), true, "other facade has independent intent ID sequence") + b.chooseMenu = function() + executions = executions + 1 + return true + end + local held = intent(other, 2, "menu", { choice = "fight" }) + T.eq(send(api, 2, "menu", { choice = "fight" }), true, "same-shaped accepted native command") + reject(other, game, held, "stale", "shared epoch invalidates unchanged records across mods") + local raw = intent(api, 3, "menu", { choice = "fight" }) + b.chooseMenu = function() + executions = executions + 1 + b.phase = "messages" + error("native boom") + end + local accepted, err = api:submit(raw) + T.eq(accepted, nil, "native exception reported") + T.check( + err:find("consumed", 1, true) ~= nil and err:find("native boom", 1, true) ~= nil, + "native error explains consumed ID" + ) + reject(api, game, raw, "replayed", "partial failure cannot replay") + T.eq(Intents.state(game).busy, false, "error clears shared busy flag") + b.phase = "menu" + b.chooseMenu = native + T.eq(send(api, 4, "menu", { choice = "fight" }), true, "new request recovers after native failure") +end + +do + local api, game, b, other, loader = boot("red") + local original = b.menuBattler + local request = intent(api, 1, "menu", { choice = "fight" }) + local nested + local otherRequest = intent(other, 1, "menu", { choice = "fight" }) + b.menuBattler = function(self) + nested = { other:submit(otherRequest) } + return original(self) + end + T.eq(api:submit(request), true, "validation rejects recursion while outer intent succeeds") + T.eq(nested[1], nil, "validation reentry rejected") + T.eq(send(api, 2, "back"), true, "validation reentry leaves facade usable") + b.menuBattler = function(self) + game.stack.states[2] = {} + return original(self) + end + local ok, err = api:submit(intent(api, 3, "menu", { choice = "fight" })) + T.eq(ok, nil, "preflight context change refused") + T.check(err:find("stale", 1, true) ~= nil, "preflight context change reason") + -- The intentionally overridden validator above changed the stack itself; + -- test the refusal separately from the normal no-side-effects contract. + game.stack.states[2] = nil + b.menuBattler = original + T.eq(Intents.state(game).busy, false, "preflight failure clears busy flag") + local raw = intent(api, 3, "menu", { choice = "fight" }) + b.menuBattler = function(self) + raw.choice = "run" + raw.id = 999 + return original(self) + end + T.eq(api:submit(raw), true, "intent copied before native validation") + T.eq(b.phase, "moveSelect", "caller mutation cannot change dispatch choice") + b.menuBattler = original + local _, newGame, newBattle = boot("blue") + loader.game = newGame + reject( + api, + newGame, + { id = 3, revision = raw.revision, kind = "back" }, + "replayed", + "consumption survives Game replacement" + ) + T.eq(send(api, 4, "menu", { choice = "fight" }), true, "captured facade follows new Game") + T.eq(newBattle.phase, "moveSelect", "action reaches replacement Game") +end + +-- Accepted calls can enter normal refusal/forced-action paths too. They do +-- not reinterpret a native trainer RUN, no-PP Struggle or trapped FIGHT. +for _, mode in ipairs({ "struggle", "trapping", "ghost", "trainerRun" }) do + local api, game, b = boot("crystal", mode == "trainerRun") + local control, cgame, cb = boot("crystal", mode == "trainerRun") + for _, bb in ipairs({ b, cb }) do + if mode == "struggle" then + for _, mv in ipairs(bb.player.curMoves) do + mv.pp = 0 + end + end + if mode == "trapping" then + bb.player.trappingTurns = 2 + end + if mode == "ghost" then + bb.ghost = true + end + end + local choice = mode == "trainerRun" and "run" or "fight" + T.eq(send(api, 1, "menu", { choice = choice }), true, mode .. ": public selection accepted") + cb.menuIndex = mode == "trainerRun" and 4 or 1 + press(cgame, cb, "a") + local actual, expected = view(api), view(control) + actual.menuIndex = nil + expected.menuIndex = nil + T.same(actual, expected, mode .. ": preserves native branch") + T.eq(#b.queue, #cb.queue, mode .. ": native queue preserved") +end + +do + local api, game, b = boot("red") + local raw = intent(api, 1, "menu", { choice = "fight" }) + local rev = raw.revision + raw.extra = "typo" + reject(api, game, raw, "field", "invalid field is observational") + T.eq(api:snapshot().revision, rev, "pure invalid intent does not advance revision") + raw.extra = nil + local native = b.chooseMenu + b.chooseMenu = function() + return nil, "native declined" + end + local ok, err = api:submit(raw) + T.eq(ok, nil, "native refusal returned") + T.eq(err, "native declined", "native refusal reason retained") + reject(api, game, raw, "replayed", "native invocation consumes even when it declines") + b.chooseMenu = native + T.eq(send(api, 2, "menu", { choice = "fight" }), true, "native decline clears busy lock") + local _, otherGame = boot("blue") + game.stack.states[1] = otherGame.stack.states[1] + reject(api, game, intent(api, 3, "back"), "Game", "foreign Game battle cannot be controlled") +end +do + local api, game, b = boot("red") + local owner = Runtime.currentMod + Runtime.currentMod = "probe" + b.chooseMenu = function() + T.eq(Runtime.currentMod, nil, "native dispatch uses engine attribution") + error("scope failure") + end + local ok = send(api, 1, "menu", { choice = "fight" }) + T.eq(ok, nil, "scoped native error returned") + T.eq(Runtime.currentMod, "probe", "native error restores caller attribution") + Runtime.currentMod = owner +end +do + local api, game, b, other, loader = boot("emerald") + local callbackCount, nested, mid = 0, nil, nil + local held = intent(other, 1, "menu", { choice = "fight" }) + loader.apis.probe.events:on("battle.turn_started", function() + callbackCount = callbackCount + 1 + T.eq(Runtime.currentMod, nil, "real event uses native engine scope") + mid = other:snapshot() + nested = { other:submit(held) } + end) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, "real event test selects FIGHT") + T.eq(send(api, 2, "move", { slot = 1 }), true, "real event test plans native turn") + T.eq(callbackCount, 1, "one native turn-start callback") + T.eq(nested[1], nil, "real event cannot recursively submit") + T.eq(mid.prompt, "locked", "real event sees locked in-flight snapshot") + reject( + other, + game, + { id = 1, revision = mid.revision, kind = "back" }, + "stale", + "real event snapshot expires after dispatch" + ) + T.eq(Runtime.currentMod, nil, "real native event restores engine/caller scope") +end +local api = boot("red", false, 1) +T.eq(send(api, 1, "menu", { choice = "fight" }), true, "API 1 mod can use additive public actions") +GameVersion.set(oldVersion) +T.finish("battle actions") diff --git a/tests/engine/battle_snapshots.lua b/tests/engine/battle_snapshots.lua index 27c992c5f..acf55273d 100644 --- a/tests/engine/battle_snapshots.lua +++ b/tests/engine/battle_snapshots.lua @@ -185,11 +185,11 @@ for _, version in ipairs(GameVersion.ORDER) do T.eq(resolves(), 0, label .. "entry can capture facade before Game exists") if api.supports then T.eq(api:supports("battle", "snapshot"), true, label .. "snapshot capability") - T.eq(api:supports("battle", "submit"), false, label .. "no input submission") + T.eq(api:supports("battle", "submit"), true, label .. "additive input submission") T.eq(resolves(), 0, label .. "probe does not resolve Game") end T.eq(api.battle, api.battle, label .. "stable facade") - T.eq(api.battle.submit, nil, label .. "no mutation method") + T.eq(type(api.battle.submit), "function", label .. "additive submit method") local no, reason = api.battle:snapshot() T.eq(no, nil, label .. "no battle") T.eq(reason, "no battle", label .. "no battle reason") From 418b55ff7359643dcb2e06c2fecfd4f2d620756b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:00:08 +0200 Subject: [PATCH 09/71] Add revision-bound public party, bag and dialogue UI actions --- docs/modding.md | 8 + docs/ui-actions-api.md | 128 ++++++ src/mods/Loader.lua | 8 +- src/mods/UIActions.lua | 241 +++++++++++ src/render/TextBox.lua | 62 ++- src/ui/BagMenu.lua | 7 +- src/ui/ChoiceBox.lua | 28 +- src/ui/Gen3PartyMenu.lua | 47 ++- src/ui/Gen4PartyMenu.lua | 40 +- src/ui/ListMenu.lua | 29 +- src/ui/Menu.lua | 39 +- src/ui/PartyMenu.lua | 778 ++++++++++++++++++----------------- src/ui/UICommands.lua | 346 ++++++++++++++++ tests/engine/ui_actions.lua | 792 ++++++++++++++++++++++++++++++++++++ 14 files changed, 2084 insertions(+), 469 deletions(-) create mode 100644 docs/ui-actions-api.md create mode 100644 src/mods/UIActions.lua create mode 100644 src/ui/UICommands.lua create mode 100644 tests/engine/ui_actions.lua diff --git a/docs/modding.md b/docs/modding.md index 023f89f5d..1e6175c38 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -230,6 +230,14 @@ The console understands these verbs (anything else is evaluated as Lua): - `trace PAT | trace off` — trace events/hooks matching a glob pattern. - `help` — list the verbs. +## Public party, bag and dialogue actions + +`mod.ui:snapshot()` returns detached options for the current native screen; +`mod.ui:submit(intent)` selects a row, goes back, changes bag pocket or advances +ready text through its native flow. See [Public UI actions](ui-actions-api.md) +for revision/ID requirements, per-generation menu behavior and supported screens. +The existing `mod.ui` list-building helpers retain their original signatures. + ## Tool input and title-menu hooks Tool mods that need to act once per game logic tick can wrap `input.step`. diff --git a/docs/ui-actions-api.md b/docs/ui-actions-api.md new file mode 100644 index 000000000..caa7c4d36 --- /dev/null +++ b/docs/ui-actions-api.md @@ -0,0 +1,128 @@ +# Public UI actions + +`mod.ui:snapshot()` and `mod.ui:submit(intent)` drive the **currently open** +native party, bag or dialogue screen. They are additive for API 1 and 2, with +no manifest opt-in. Existing `mod.ui.insertBefore`, `insertAfter`, step-list +helpers and their dot-call signatures remain available. Each mod receives its +own facade. If the optional capability-query feature is installed, +`mod:supports("ui", "snapshot")` and `mod:supports("ui", "submit")` return true. +This describes the interface, not whether the current screen accepts an action. + +The battle API can open the native party/bag menu. This API then selects its +rows and follows the native target, item, confirmation and message flow. It +does not open a menu from arbitrary world/script states. + +## Snapshot and intents + +A snapshot returns a detached table, or `nil, reason` if the top screen is +absent or unsupported. It never searches through a covering screen. Reading +early does not bootstrap Game, load a screen, poll input or render graphics. + +| Field | Meaning | +| --- | --- | +| `revision` | Positive per-facade context revision; obtain it from this facade | +| `kind` | `party`, `party_actions`, `bag`, `bag_actions`, `text` or `choice` | +| `rows` | Ordered options, including disabled placeholders; empty for text | +| `index` | Native selected row, where applicable | +| `locked` | Animation, typing, controller or script currently owns input | +| `canBack`, `canAdvance`, `canPocket` | Which non-selection intents are offered | +| `pocket` | Optional native pocket identifier; diagnostic, not portable | +| `page` | DS battle bag stage: `menu`, `pocket` or `use` | + +Every row has `index` and `enabled`. Depending on the native screen it can also +carry a string `label`/`action`, portable `item`/`species` ID, item `count`, or +`partyIndex`, `hp`, `maxHp`. Missing fields remain absent. The current dataset +resolves IDs, including numeric DS IDs and ROM-hack aliases. Labels can be +localized; native action strings are diagnostic, not a common semantic action +enumeration. There are no callbacks, native objects or future dialogue pages +in the public record. + +`select.index` is **1-based in the returned rows**. It is not always a party +slot or item number: party confirmation/cancel rows, DS list headers, GBA +blank action cells, and DS battle-bag navigation buttons retain their native +positions. Disabled rows cannot be submitted. In field bags the rows cover the +current pocket in native order; HGSS/BW scroll/page cursors are positioned for +the selected row. DS battle bags expose only their current page's six item +slots plus previous/next/back buttons. + +Intents must be plain tables with exactly the fields below: + +```lua +{ id = 1, revision = snapshot.revision, kind = "select", index = 2 } +{ id = 2, revision = snapshot.revision, kind = "back" } +{ id = 3, revision = snapshot.revision, kind = "advance" } +{ id = 4, revision = snapshot.revision, kind = "pocket", direction = 1 } +``` + +`id` is a strictly increasing positive safe integer **per UI facade**; +`revision` must come from a fresh snapshot returned by that facade. Pocket +`direction` is exactly `-1` or `1`. Unknown fields/kinds, malformed integers, +missing/disabled rows, stale contexts and unavailable actions return +`nil, reason` before native dispatch. Failed admission does not consume the ID. +Keep the ID counter across menu/Game changes. + +```lua +local nextId = 0 +local function chooseRow(index) + local snapshot, why = mod.ui:snapshot() + if not snapshot then return nil, why end + local row = snapshot.rows[index] + if snapshot.locked or not row or not row.enabled then + return nil, "row is not currently available" + end + nextId = nextId + 1 + return mod.ui:submit({ + id = nextId, revision = snapshot.revision, kind = "select", index = index, + }) +end +``` + +Take a new snapshot after each accepted action or native input/update. Revisions +track native screen/option/callback identity, live party/inventory and relevant +selection/control state. A private per-Game guard/epoch serializes UI and battle +submissions, rejects callback reentry and expires other mods' old and in-flight +snapshots. A snapshot obtained during dispatch reports `locked = true`. + +## Native behavior and limits + +- Party lists/pickers, swap/field/item submenus, forced replacement and team + selection retain their native eligibility, callback and cancellation paths. + `back` uses that screen's B behavior; it may clear a pending swap or submenu + instead of closing. The underlying battle decides how a cancelled forced + replacement is reopened. GB/GBA/DS each keep their own return semantics. +- Bags preserve native pocket order, item-use eligibility, target pickers and + consumption. The classic bag's own action menu and GBA item action grid are + supported; unrelated generic menus/lists are not inferred from their shape. + DS battle-bag presses complete on subsequent native updates, including their + page/category return paths. `true` does not mean an item has already run. +- `advance` handles a ready manual page/line break or final text, a bag's + result message, or a DS party notice. Typing and page/scroll holds are never + skipped. Multi-page question/auto/stay text can advance its intermediate + manual pages; the final script-owned page stays with its controller. +- TextBox's native choice creates the separate YES/NO screen on the next + native update. `select` chooses YES/NO; `back` selects NO. The native answer + hold and delayed callback are preserved, so the answer cannot be replayed + while pending. +- Scripts, no-input/tutorial states, healing fills, pending answer/DS battle + button animations, item sorting and quantity entry are locked. QuantityBox, + summary screens, PC/shop-specific auxiliary screens, TM cases/berry pouches, + arbitrary custom menus and custom screen subclasses have no adapter here; + they return `nil, reason`. This is not a generic UI automation API. + +`true` means a native action was admitted and entered. It may open another +screen, display a native refusal or schedule work. IDs are consumed **before** +native code. An error after partial native effects returns +`nil, "UI action failed; intent consumed: ..."`; the guard is released, but no +rollback or automatic retry occurs. Inspect a fresh snapshot before continuing. +The read/validation boundary is not a security sandbox for mods that directly +replace native engine methods or mutate Game themselves. + +## Reproducible tests + +Run `luajit tests/engine/ui_actions.lua` and `LUA=luajit bash scripts/test.sh`. +The focused suite uses real Loader/native menus, Pokémon, item effects and text +updates with the ROM-free fixture data across all 15 supported version IDs. +It checks native A/B parity, copied reads, full-graph immutability on refusal, +identity/revision/replay checks, callbacks and recovery after partial errors. +DS UI fixtures include native pocket/answer/press delays. Imported-ROM gameplay +and rendered cartridge scenes require a separate ROM-backed run. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 08e71b498..7a2d130fc 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -22,6 +22,7 @@ local Hooks = require("src.mods.Hooks") local ModStorage = require("src.mods.Storage") local Runtime = require("src.mods.Runtime") local BattleSnapshots = require("src.mods.BattleSnapshots") +local UIActions = require("src.mods.UIActions") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") -- Module-name aliases for mods written against the Gold port's per-generation @@ -918,9 +919,9 @@ function Loader:_api(mod) hooks = { wrap = function(_, name, callback, priority) return loader.hooks:wrap(name, callback, priority, modId) end }, - -- the widget toolkit facade (12 4.5) is one shared surface, not - -- per-mod state; each widget inside it loads on first touch - ui = ModUI, + -- Per-mod action/revision state, with the existing lazy widget builders. + -- Reading the facade early must not construct Game or a native screen. + ui = UIActions.new(function() return loader:_game(true) end, loader, ModUI), -- namespaced per mod; M11 backs these with save.modData / -- options.modOptions, the shape mods compile against is already final save = { @@ -1127,6 +1128,7 @@ function Loader:_api(mod) -- the same reason: a headless load must not drag the world stack in. -- Battle capability queries can also be installed before Game exists. BattleSnapshots.withCapabilities(api) + UIActions.withCapabilities(api) -- The Game the facade acts on is still being wired when the entry chunk -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. diff --git a/src/mods/UIActions.lua b/src/mods/UIActions.lua new file mode 100644 index 000000000..533730961 --- /dev/null +++ b/src/mods/UIActions.lua @@ -0,0 +1,241 @@ +-- Per-mod, detached UI snapshots and revision-bound native actions. +local Commands = require("src.ui.UICommands") +local Intents = require("src.mods.BattleIntents") +local Ids = require("src.mods.UnifiedIds") +local Runtime = require("src.mods.Runtime") +local Actions = {} +local function copy(v, seen) + if type(v) ~= "table" then + if type(v) == "string" or type(v) == "number" or type(v) == "boolean" then + return v + end + return nil + end + seen = seen or {} + if seen[v] then + return seen[v] + end + local out = {} + seen[v] = out + for k, value in pairs(v) do + if type(k) == "string" or type(k) == "number" then + out[k] = copy(value, seen) + end + end + return out +end +local function equal(a, b, seen) + if type(a) ~= type(b) then + return false + end + if type(a) ~= "table" then + return a == b or (a ~= a and b ~= b) + end + seen = seen or {} + if seen[a] then + return seen[a] == b + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end +local function engine(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local ok, a, b, c = pcall(fn, ...) + Runtime.currentMod = owner + return ok, a, b, c +end +local function integer(n) + return type(n) == "number" and n >= 1 and n <= 9007199254740991 and n % 1 == 0 +end +local function normalize(raw) + if type(raw) ~= "table" or getmetatable(raw) ~= nil then + return nil, "intent must be a plain table" + end + if not integer(raw.id) or not integer(raw.revision) then + return nil, "invalid UI id or revision" + end + local fields = ({ select = { index = true }, back = {}, advance = {}, pocket = { direction = true } })[raw.kind] + if not fields then + return nil, "unknown UI intent" + end + for k in pairs(raw) do + if k ~= "id" and k ~= "revision" and k ~= "kind" and not fields[k] then + return nil, "unknown UI intent field" + end + end + if raw.kind == "select" and not integer(raw.index) then + return nil, "invalid UI row" + end + if raw.kind == "pocket" and raw.direction ~= -1 and raw.direction ~= 1 then + return nil, "invalid pocket direction" + end + return { + id = raw.id, + revision = raw.revision, + kind = raw.kind, + index = raw.index, + direction = raw.direction, + } +end +function Actions.new(provider, loader, builders) + local ids = Ids.new(loader) + ids:entries("pokemon") + ids:entries("items") + local facade = setmetatable({}, { __index = builders }) + local revision, consumed = 0, 0 + local previous, oldRefs, oldControls, oldEpoch, offered + local function refresh() + local game = provider() + local states = game and game.stack and game.stack.states + local top = states and states[#states] + if not top then + previous, oldRefs, oldControls, oldEpoch, offered = nil, nil, nil, nil, nil + return nil, "no UI screen" + end + local ok, view, refs, kind = engine(Commands.describe, game, top, Ids.new(loader)) + if not ok or not view then + previous, oldRefs, oldControls, oldEpoch, offered = nil, nil, nil, nil, nil + return nil, ok and refs or "UI snapshot failed" + end + refs.game = game + -- Capture live eligibility, target and inventory changes even when the + -- native screen has not rebuilt its labels yet. Never return this data. + local controls = copy({ + party = type(top.party) == "table" and top.party or game.save and game.save.party, + inventory = game.save and game.save.inventory, + bagOrder = game.save and game.save.bagOrder, + select = top.select, + menu = top.menu, + submenu = top.submenu, + swapFrom = top.swapFrom, + switchFrom = top.switchFrom, + softboiledFrom = top.softboiledFrom, + hpTransfer = top.hpTransfer, + hpTransferFrom = top.hpTransferFrom, + forceSwitch = top.forceSwitch, + pickOnly = top.pickOnly, + keepOpen = top.keepOpen, + pick = top.pick, + store = top.store, + sell = top.sell, + registeredItem = game.save and game.save.registeredItem, + cur = top.cur, + mem = top.mem, + page = top.page, + top = top.top, + done = top.done, + waiting = top.waiting, + shown = top.shown, + contAdvance = top.contAdvance, + lineIndex = top.lineIndex, + pageIndex = top.pageIndex, + pending = top.pending, + trash = top.trash, + moving = top.moving, + lockPocket = top.lockPocket, + resultMessage = top.resultMessage, + message = top.message, + selectNotice = top.selectNotice, + }) + local state = Intents.state(game) + if + not equal(view, previous) + or not Intents.sameRefs(refs, oldRefs) + or not equal(controls, oldControls) + or state.epoch ~= oldEpoch + then + revision = revision + 1 + end + previous, oldRefs, oldControls, oldEpoch = view, refs, controls, state.epoch + return { game = game, top = top, view = view, kind = kind, state = state } + end + function facade:snapshot() + local ctx, err = refresh() + if not ctx then + return nil, err + end + local out = copy(ctx.view) + out.revision = revision + if ctx.state.busy then + out.locked, out.canBack, out.canAdvance, out.canPocket = true, false, false, false + end + offered = revision + return out + end + function facade:submit(raw) + local intent, err = normalize(raw) + if not intent then + return nil, err + end + if intent.id <= consumed then + return nil, "replayed UI intent" + end + local ctx + ctx, err = refresh() + if not ctx then + return nil, err + end + local state, view = ctx.state, ctx.view + if state.busy then + return nil, "UI action is already in progress" + end + if intent.revision ~= revision then + return nil, "stale UI context" + end + if offered ~= revision then + return nil, "fresh UI snapshot required" + end + if view.locked then + return nil, "UI is locked" + end + if intent.kind == "select" then + if not view.rows[intent.index] or not view.rows[intent.index].enabled then + return nil, "unavailable UI row" + end + elseif intent.kind == "back" and not view.canBack then + return nil, "UI cannot go back" + elseif intent.kind == "advance" and not view.canAdvance then + return nil, "UI cannot advance" + elseif intent.kind == "pocket" and not view.canPocket then + return nil, "UI cannot change pocket" + end + -- Native work can raise after partial effects. Consume before dispatch; + -- the shared epoch also expires snapshots held by battle/other UI facades. + consumed, offered = intent.id, nil + state.busy, state.epoch = true, state.epoch + 1 + local ok, failure = engine(Commands.dispatch, ctx.top, ctx.kind, view, intent) + state.busy, state.epoch = false, state.epoch + 1 + if not ok then + local printable, message = pcall(tostring, failure) + return nil, "UI action failed; intent consumed" .. (printable and ": " .. message or "") + end + return true + end + return facade +end +function Actions.withCapabilities(api) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "ui" then + if name == "snapshot" or name == "submit" then + return true + end + return false, "unknown UI capability: " .. tostring(name) + end + return supports(self, surface, name) + end + end +end +return Actions diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 07f605295..359964018 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -420,6 +420,44 @@ function TextBox:beginLine() table.insert(self.shown, {}) end +-- Only the manual input branches; typing, automatic text and holds still +-- belong to update(), including the delay after a page/line advance. +function TextBox:advance() + if (self.holdFrames or 0) > 0 or (self.preWait or 0) > 0 then + return + end + if self.done then + if self.stay or self.auto or self.choice then + return + end + require("src.core.Sound").play(self.game.data, "Press_AB") + self.game.stack:pop() + if self.onDone then + self.onDone() + end + elseif self.waiting then + require("src.core.Sound").play(self.game.data, "Press_AB") + self.waiting = false + if self.contAdvance then + -- ContText / ManualTextScroll: keep the box, scroll one line + self.contAdvance = false + self.lineIndex = self.lineIndex + 1 + self:beginLine() + -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment + -- says, is "always called twice in a row" (home/text.asm:280-305) + self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR + else + self.shown = {} + self.pageIndex = self.pageIndex + 1 + self.lineIndex = 1 + self:beginLine() + -- ClearScreenArea then DelayFrames 20: the box sits empty before the + -- next page starts typing (home/text.asm:236-240) + self.holdFrames = Timing.TEXT_PAGE_CLEAR + end + end +end + function TextBox:update(dt) local input = self.game.input self.blink = (self.blink + 1) % 60 @@ -519,9 +557,7 @@ function TextBox:update(dt) return end if input:wasPressed("a") or input:wasPressed("b") then - require("src.core.Sound").play(self.game.data, "Press_AB") - self.game.stack:pop() - if self.onDone then self.onDone() end + self:advance() end return end @@ -534,25 +570,7 @@ function TextBox:update(dt) return end if input:wasPressed("a") or input:wasPressed("b") then - require("src.core.Sound").play(self.game.data, "Press_AB") - self.waiting = false - if self.contAdvance then - -- ContText / ManualTextScroll: keep the box, scroll one line - self.contAdvance = false - self.lineIndex = self.lineIndex + 1 - self:beginLine() - -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment - -- says, is "always called twice in a row" (home/text.asm:280-305) - self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR - else - self.shown = {} - self.pageIndex = self.pageIndex + 1 - self.lineIndex = 1 - self:beginLine() - -- ClearScreenArea then DelayFrames 20: the box sits empty before the - -- next page starts typing (home/text.asm:236-240) - self.holdFrames = Timing.TEXT_PAGE_CLEAR - end + self:advance() end return end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 4d229471d..4020375b6 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -1234,11 +1234,14 @@ function BagMenu.new(game, opts) options[#options + 1] = { label = Strings("QUIT"), onSelect = function() end } end local th = #options * 2 + 1 - game.stack:push(Menu.new(game, options, gen2 + local actions = Menu.new(game, options, gen2 and { tx = 0, ty = 12 - th, tw = 7, th = th } - or { tx = 13, ty = 10, tw = 7, th = th })) + or { tx = 13, ty = 10, tw = 7, th = th }) + actions.publicUIKind = "bag_actions" + game.stack:push(actions) end, }) + list.publicUIKind, list.publicUIBattle = "bag", battle if gen2 then list.sgbPalettes = packPalettes end return list end diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 1f9f41f81..ba7edbdd0 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -30,6 +30,18 @@ function ChoiceBox.new(game, onChoose, opts) return self end +function ChoiceBox:choose(index) + if self.pending ~= nil then + return + end + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.index = index + self.pending = index == 1 + self.holdFrames = Timing.YES_NO_ANSWER +end + function ChoiceBox:update(dt) local input = self.game.input -- Both branches of DisplayTwoOptionMenu hold 15 frames with the menu still @@ -48,21 +60,9 @@ function ChoiceBox:update(dt) if input:wasPressed("up") or input:wasPressed("down") then self.index = self.index == 1 and 2 or 1 elseif input:wasPressed("a") then - -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on A and B alike - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - self.pending = (self.index == 1) - self.holdFrames = Timing.YES_NO_ANSWER + self:choose(self.index) elseif input:wasPressed("b") then - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - -- .choseSecondMenuItem writes wCurrentMenuItem = 1 before the hold, so - -- the cursor visibly snaps to NO for those 15 frames - self.index = 2 - self.pending = false - self.holdFrames = Timing.YES_NO_ANSWER + self:choose(2) end end diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 4f3f78b39..855f2c805 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -1015,6 +1015,38 @@ function Gen3PartyMenu:animateTo(mon, fromHP, onDone) self.heal = { mon = mon, from = from, shown = from, onDone = onDone } end +function Gen3PartyMenu:confirm() + if self.index > #self:party() then + if self.hpTransferFrom then + self.hpTransferFrom = nil + return + end + -- the CANCEL button VALIDATES when a team is being chosen; everywhere + -- else it is the way out + if self.chooseOrder then + return self:confirmOrder() + end + return self:close() + end + self:choose() +end + +function Gen3PartyMenu:cancel() + if self.hpTransferFrom then + self.hpTransferFrom = nil + return + end + -- B drops a SWITCH that was half-made before it closes the screen + if self.switchFrom then + self.switchFrom = nil + return + end + if self.chooseOrder then + return self:handOrder(nil) + end + self:close() +end + function Gen3PartyMenu:update(dt) -- RECONSTRUCTED: every icon bounces, all the time, on the same beat. On -- the cartridge each one is an OBJ with its own callback; here it is one @@ -1073,20 +1105,9 @@ function Gen3PartyMenu:update(dt) self.index = 1 end elseif input:wasPressed("a") then - if self.index > #self:party() then - if self.hpTransferFrom then self.hpTransferFrom=nil;return end - -- the CANCEL button VALIDATES when a team is being chosen; everywhere - -- else it is the way out - if self.chooseOrder then return self:confirmOrder() end - return self:close() - end - self:choose() + self:confirm() elseif input:wasPressed("b") then - if self.hpTransferFrom then self.hpTransferFrom=nil;return end - -- B drops a SWITCH that was half-made before it closes the screen - if self.switchFrom then self.switchFrom = nil return end - if self.chooseOrder then return self:handOrder(nil) end - self:close() + self:cancel() end end diff --git a/src/ui/Gen4PartyMenu.lua b/src/ui/Gen4PartyMenu.lua index ce9163880..851b8ec29 100644 --- a/src/ui/Gen4PartyMenu.lua +++ b/src/ui/Gen4PartyMenu.lua @@ -876,6 +876,28 @@ function Gen4PartyMenu:step(dir) end end +function Gen4PartyMenu:cancel() + if self.switchFrom then + self.switchFrom = nil + elseif self.hpTransfer then + self.hpTransfer = nil + -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING". + -- + -- Out of a battle, handing the answer back as nil is how a caller learns + -- the player cancelled, and that is right. IN a battle it crashed: the + -- battle's own onSwitch is written for a Pokemon and went straight into + -- `mon.hp`. The battle already knows what to do when this screen simply + -- closes, so closing is both the safe answer and the correct one. + elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) and not self.battle then + self:handOff(nil) + elseif self.select then + self.index = self:count() + self:choose() + else + self:close() + end +end + function Gen4PartyMenu:update(dt) self.t = (self.t or 0) + (dt or 1 / 60) local input = self.game.input @@ -908,23 +930,7 @@ function Gen4PartyMenu:update(dt) elseif input:wasPressed("a") then self:choose() elseif input:wasPressed("b") or input:wasPressed("start") then - if self.switchFrom then self.switchFrom = nil - elseif self.hpTransfer then self.hpTransfer = nil - -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING". - -- - -- Out of a battle, handing the answer back as nil is how a caller learns - -- the player cancelled, and that is right. IN a battle it crashed: the - -- battle's own onSwitch is written for a Pokemon and went straight into - -- `mon.hp`. The battle already knows what to do when this screen simply - -- closes, so closing is both the safe answer and the correct one. - elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) - and not self.battle then - self:handOff(nil) - elseif self.select then - self.index = self:count(); self:choose() - else - self:close() - end + self:cancel() end end diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index e93098790..cf42a71f2 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -172,6 +172,25 @@ local function navPressed(self, dir) return true end +function ListMenu:choose() + if #self.items == 0 then + return self:cancel() + end + beep(self) + local item = self.items[self.index] + if self.onChoose then + self.onChoose(item, self) + end +end + +function ListMenu:cancel() + beep(self) + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function ListMenu:update(dt) if self.script then self.script(self) @@ -211,16 +230,10 @@ function ListMenu:update(dt) elseif self.onSelectKey and input:wasPressed("select") then self.onSelectKey(self.items[self.index], self) elseif input:wasPressed("b") then - beep(self) - self.game.stack:pop() - if self.onCancel then self.onCancel() end + self:cancel() return elseif input:wasPressed("a") then - beep(self) - local item = self.items[self.index] - if self.onChoose then - self.onChoose(item, self) - end + self:choose() return end diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 2415d798b..24b36069d 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -120,6 +120,35 @@ function Menu:clampScroll() end end +function Menu:choose() + -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + local item = self.items[self.index] + -- keepOpen entries run without closing the menu (e.g. the + -- Pokédex CRY option keeps the side menu up) + if not item.keepOpen then + self.game.stack:pop() + end + if item.onSelect then + item.onSelect() + end +end + +function Menu:cancel() + if not self.cancelable then + return + end + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function Menu:update(dt) local input = self.game.input local wasIndex = self.index @@ -128,15 +157,7 @@ function Menu:update(dt) elseif input:wasPressed("down") then self.index = self.index < #self.items and self.index + 1 or 1 elseif input:wasPressed("a") then - -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - local item = self.items[self.index] - -- keepOpen entries run without closing the menu (e.g. the - -- Pokédex CRY option keeps the side menu up) - if not item.keepOpen then self.game.stack:pop() end - if item.onSelect then item.onSelect() end + self:choose() elseif self.cancelable and (input:wasPressed("b") or (self.startCloses and input:wasPressed("start"))) then -- HandleMenuInput_ returns for any watched key, but only replays diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index e59d7173b..4b487d623 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -472,6 +472,399 @@ function PartyMenu:close() if self.game.stack:top() == self then self.game.stack:pop() end end +-- Native confirmation shared by input and the public UI facade. +function PartyMenu:confirm() + local party = self.party or self.game.save.party + if self.submenu then + local mon = party[self.index] + local entry = self.subItems[self.subIndex] + local action = entry.action + if not action and entry.onSelect then + -- hook-injected entries carry a callback instead of an action id + entry.onSelect(mon, self.game) + elseif action == "stats" then + -- battle and field alike return to the party list afterwards + -- (core.asm .partyMenuWasSelected) + Screens.push(self.game, "SummaryMenu", mon, { + mons = party, + index = self.index, + onMonChange = function(index) self.index = index end, + }) + elseif action == "battle_switch" then + self.game.stack:pop() + self.onSwitch(mon) + return + elseif action == "cancel" then + self.game.stack:pop() + if self.onCancel then self.onCancel() end + return + elseif action == "switch" then + self.swapFrom = self.index + elseif action == "item" then + self:openItemMenu(mon) + elseif action == "quit" then + self.submenu = nil + elseif action == "fly" then + -- FLY opens the TOWN MAP with a cursor over the visited fly towns, + -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly). + -- flyTo (OverworldController) validates the fly-warp + runs the + -- departure/warp, so we just hand it the chosen mapId (#195). + local ow = self.game.overworld + self.game.stack:pop() -- close the party menu + Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) + if ow then ow:flyTo(mapId) end + end }) + return + elseif action == "flash" then -- FLASH lights dark tunnels + -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs + -- with the party menu still on screen, and only then + -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads + -- over the menu, and the cave is lit when the blink hands the + -- screen back, never under the text (#385). + local ow = self.game.overworld + local TextBox = require("src.render.TextBox") + local Transition = require("src.render.Transition") + -- The other half of CheckUseFlash: using it in the Aerodactyl chamber + -- opens the wall. The chamber's scene script plays the opening the + -- next time the map loads, exactly as the escape-rope one does. + require("src.script.RuinsOfAlph").aerodactylChamber(self.game) + self.game.save.flashLit = true + self.game.stack:push(TextBox.new(self.game, + self.game.data.text._FlashLightsAreaText + or Strings("A blinding FLASH\nlights the area!"), function() + self:close() + -- setDark, not a bare field write: ADVANCED carries the darkness + -- in a baked atlas, so lighting the cave drops every resident map + -- and rebakes this one (#383). It runs HERE, before the blink, + -- because start_sub_menus.asm .flash clears wMapPalOffset before + -- PrintText and blinks last of all: the cave is already lit by the + -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the + -- blink's completion instead left that rebuild's whole cost -- + -- seconds of per-pixel atlas baking on a phone -- on screen as a + -- solid white frame with nothing under it, which reads as a + -- lockup (#610). + ow:setDark(false) + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + return + elseif action == "surf" then + -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time + -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F + -- current refusals, both of which loop back to the submenu), then + -- ItemUseSurfboard: while surfing it tries to dismount instead; + -- otherwise it mounts only if the FACING tile is water, else + -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the + -- submenu. useSurfFieldMove reports which; trySurf does the mount. + local ow = self.game.overworld + local reason = ow:useSurfFieldMove() + local Transition = require("src.render.Transition") + if reason == "ok" then + -- UseItem prints _SurfingGotOnText with the party menu still up; + -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so + -- trySurf closes this menu when its text does (#385) + local fx, fy = ow.player:facingCell() + ow:trySurf(fx, fy, function() self:close() end) + return + end + if reason == "dismount" then + -- ItemUseSurfboard .stopSurfing: no text -- the walking state + -- and music return first (PlayDefaultMusic + + -- LoadWalkingPlayerSpriteGraphics), the menu closes with the + -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press + -- steps the player forward onto land (or across a connection + -- strip when the shore is the next map's edge) + self.game.stack:pop() + ow.player.surfing = false + require("src.core.Music").setSurfing(self.game.data, false) + self.game.stack:push(Transition.whiteFlash(self.game, nil, function() + ow:stepForwardOrCrossEdge(ow.player.facing) + end)) + return + end + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local key = ({ no_badge = "_NewBadgeRequiredText", + forced_bike = "_CyclingIsFunText", + current = "_CurrentTooFastText", + no_place = "_SurfingNoPlaceToGetOffText" })[reason] + or "_NoSurfingHereText" + local txt = (self.game.data.text[key] or Strings("No SURFing here!")) + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + if reason == "no_place" then + -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but + -- never zeroes wActionResultOrTookBattleTurn, so unlike the + -- other refusals the menu still closes afterwards + -- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints + -- over the still-open menu like every other .loop refusal (#385) + self.game.stack:push(TextBox.new(self.game, txt, function() + self:close() + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + return + end + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + elseif action == "cut" then + -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): + -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the + -- submenu when the FACING tile isn't a cuttable tree. + local ow = self.game.overworld + local reason = ow:useCutFieldMove() + if reason == "ok" then + self.game.stack:pop() -- close the party menu (CloseTextDisplay) + local fx, fy = ow.player:facingCell() + ow:tryCut(fx, fy) + return + end + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local key = (reason == "no_badge") and "_NewBadgeRequiredText" + or "_NothingToCutText" + local txt = (self.game.data.text[key] or Strings("Nothing to CUT!")) + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + elseif action == "strength" then + -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); + -- predef PrintStrengthText (field_move_messages.asm) sets + -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate + -- push_boulder.asm reads -- then prints _UseStrengthText (no + -- prompt: after the text, the text_asm tail plays the chosen + -- mon's cry, Delay3, and it auto-advances) and + -- _MoveBoulderText (`prompt`: waits for A/B). Back in + -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white + -- before CloseTextDisplay returns to the map. + local ow = self.game.overworld + local TextBox = require("src.render.TextBox") + local Transition = require("src.render.Transition") + local def = self.game.data.pokemon[mon.species] + local name = mon.nickname or (def and def.name) or mon.species + ow.strengthActive = true + -- Gen2 text keys are _UseStrengthText / _MoveBoulderText; + -- Gen1 uses _UsedStrengthText / _CanMoveBouldersText. Try both. + local t1raw = self.game.data.text._UseStrengthText + or self.game.data.text._UsedStrengthText + local t2raw = self.game.data.text._MoveBoulderText + or self.game.data.text._CanMoveBouldersText + local t1 = (t1raw or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) + local t2 = (t2raw or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name) + -- like surf (#320, #385): both texts print with the party menu + -- still on screen, and the blink IS the menu closing afterwards, + -- not a flashbang on the empty map + self.game.stack:push(TextBox.new(self.game, t1, function() + self.game.stack:push(TextBox.new(self.game, t2, function() + self:close() + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + end, { auto = { sound = function() + return require("src.core.Sound").playCry(self.game.data, mon.species) + end } })) + return + elseif action == "gen2_field" then + -- GSC runs the same handler from the menu as from the overworld + -- A press (SurfFromMenuScript / HeadbuttFromMenuScript), so the + -- tile check lives in one place. + local ow = self.game.overworld + local fx, fy = ow.player:facingCell() + self:close() + if not ow:gen2FieldMoveAt(entry.move, mon, fx, fy) then + local TextBox = require("src.render.TextBox") + -- _CantSurfText is "You can't SURF here", so using it for every + -- field move told a player holding HEADBUTT that the game thought + -- they had picked SURF. Only SURF gets the surf line. + local text = (entry.move == "SURF") + and self.game.data.text._CantSurfText or nil + self.game.stack:push(TextBox.new(self.game, + text or Strings("You can't use that\nhere."))) + end + return + elseif action == "softboiled" then + -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer + -- 1/5 of the user's max HP to a chosen teammate + self.softboiledFrom = self.index + elseif action == "dig" then + -- GEN 2 (.DoDig): back out through the recorded entrance, arriving as + -- a DOOR warp so the player steps out of the opening rather than + -- standing in it. GEN 1's DIG is ItemUseEscapeRope and shares + -- TELEPORT's Pokemon Center warp, so it falls through to the same + -- departure with no target. + local ow = self.game.overworld + self.game.stack:pop() + if ow then + local escape = require("src.core.GameVersion").isGen2() + and ow.escapePoint and ow:escapePoint() and true or nil + ow:beginTeleportOut(nil, { escape = escape }) + end + return + elseif action == "escape" then + -- TELEPORT warps to the last Pokémon Center TOWN (wLastBlackoutMap, + -- special_warps.asm escape warp) -- and Gen 2 agrees: TeleportFunction + -- .TryTeleport requires an OUTDOOR map and warps to wLastSpawnMapGroup + -- / wLastSpawnMapNumber, so this destination is right for both + -- generations. Only DIG diverged; it has its own branch above. + -- pokered's .dig/.teleport spin the + -- player up (LeaveMapAnim), white/fade out, then land it; this port + -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut + -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares + -- the exact departure; the fade + warp fire when the spin ends. + local ow = self.game.overworld + self.game.stack:pop() + if ow then ow:beginTeleportOut() end + return + end + self.submenu = nil + return + end + if #party == 0 then return end + local mon = party[self.index] + if self.softboiledFrom then + local user = party[self.softboiledFrom] + local heal = math.floor(user.stats.hp / 5) + if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp + or user.hp <= heal then + self.softboiledFrom = nil + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect."))) + else + user.hp = user.hp - heal + mon.hp = math.min(mon.stats.hp, mon.hp + heal) + self.softboiledFrom = nil + require("src.core.Sound").play(self.game.data, "Heal_HP") + local def = self.game.data.pokemon[mon.species] + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, + Strings("%s's HP\nwas restored!", mon.nickname or def.name))) + end + elseif self.swapFrom then + if self.swapFrom ~= self.index then + party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] + require("src.core.Sound").play(self.game.data, "Swap") + end + self.swapFrom = nil + elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then + -- keepOpen callers (HP medicine) need the menu still drawn while the + -- bar fills and the message prints, and close it themselves; everyone + -- else keeps the old pop-then-call order. Popping first is what made + -- a POTION snap the picker shut before the item had even run (#252). + if not self.keepOpen then self.game.stack:pop() end + self.onSwitch(mon, self) + else + self.submenu = true + self.subIndex = 1 + local items + local ow = self.game.overworld + if self.battle and self.onSwitch then + -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) + items = { { label = Strings("SWITCH"), action = "battle_switch" }, + { label = Strings("STATS"), action = "stats" }, + { label = Strings("CANCEL"), action = "cancel" } } + else + -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm + -- builds the same dynamic list) + items = { { label = Strings("STATS"), action = "stats" }, + { label = Strings("SWITCH"), action = "switch" } } + -- Field moves (HMs/TMs) are usable out of battle even when the mon + -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. + -- Battle still excludes this list via `not self.battle`. Softboiled + -- can appear for a fainted user; its heal transfer then no-ops. + if not self.battle and ow then + -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- + -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) + local outside = Map.isOutside(ow.map.def, + FieldDefaults.field(self.game.data, "outsideTilesets")) + -- The badge gate per field move comes from constants.hmBadges and + -- the badge itself through Badges.has: Gen2 gates the same moves on + -- the Johto badges and stores them as engine flags rather than bag + -- items, so the hard-coded R/B item names listed nothing at all. + local gates = FieldDefaults.constant(self.game.data, "hmBadges") or {} + local function badged(moveId) + local gate = gates[moveId] + if not (gate and gate.badge) then return true end + return Badges.has(self.game.save, { id = gate.badge }) + end + for _, mv in ipairs(mon.moves) do + if mv.id == "FLY" and outside and badged("FLY") then + table.insert(items, { label = Strings("FLY"), action = "fly" }) + -- LIGHT is carved on the Aerodactyl chamber wall, and the + -- chamber is not a dark cave -- so CheckUseFlash asks + -- SpecialAerodactylChamber FIRST and lets FLASH be used on its + -- carry, before it ever looks at the palset + -- (engine/events/overworld.asm:285). Without this the move is + -- never even offered there and the wall can never open. + elseif mv.id == "FLASH" and badged("FLASH") + and (ow.dark or (ow.map and ow.map.id + == require("src.script.RuinsOfAlph").MAPS.aerodactyl)) then + table.insert(items, { label = Strings("FLASH"), action = "flash" }) + elseif mv.id == "CUT" and badged("CUT") then + -- CUT/SURF/STRENGTH are party-menu field moves too + -- (start_sub_menus.asm .outOfBattleMovePointers); listed here + -- with the same list-time badge filter this file already uses + -- for FLY/FLASH. The facing-tile/activation check happens on + -- selection (useCutFieldMove/useSurfFieldMove). + table.insert(items, { label = Strings("CUT"), action = "cut" }) + elseif mv.id == "SURF" and badged("SURF") then + table.insert(items, { label = Strings("SURF"), action = "surf" }) + elseif mv.id == "STRENGTH" and badged("STRENGTH") then + table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) + elseif mv.id == "SOFTBOILED" or mv.id == "MILK_DRINK" then + table.insert(items, { label = Strings(mv.id:gsub("_", " ")), + action = "softboiled" }) + elseif mv.id == "TELEPORT" and outside then + -- TELEPORT works only OUTDOORS (start_sub_menus.asm + -- .teleport -> CheckIfInOutsideMap); dark maps don't + -- block it + table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) + elseif mv.id == "DIG" and ow.map.id ~= "AGATHAS_ROOM" + and (DIG_TILESETS[ow.map.def.tileset] + or (GEN2_DIG_ENVIRONMENTS[ow.map.def.environment] + and ow.escapePoint and ow:escapePoint())) then + -- GEN 1: DIG runs ItemUseEscapeRope (.dig sets wCurItem = + -- ESCAPE_ROPE): usable in the dungeon tilesets of + -- escape_rope_tilesets.asm minus Agatha's room, even in + -- the dark (Rock Tunnel). + -- GEN 2: .CheckCanDig instead -- CAVE or DUNGEON, and a + -- recorded entrance to come back out of. Its own action, + -- because Gen 2's DIG and TELEPORT no longer share a + -- destination the way Gen 1's did. + table.insert(items, { label = Strings("DIG"), action = "dig" }) + elseif GEN2_FIELD_MOVES[mv.id] and badged(mv.id) then + -- GSC's own field moves; the tile check happens on selection, + -- exactly like CUT's (start_sub_menus.asm never gates the list + -- on where the player is standing) + table.insert(items, { label = Strings(mv.id:gsub("_", " ")), + action = "gen2_field", move = mv.id }) + end + end + end + if require("src.core.GameVersion").isGen2() then + -- GSC's MonMenu lists the field moves first, then STATS, SWITCH, + -- ITEM and QUIT; Gen 1 has no held items and no QUIT row. + local stats, switch = table.remove(items, 1), table.remove(items, 1) + items[#items + 1] = stats + items[#items + 1] = switch + items[#items + 1] = { label = Strings("ITEM"), action = "item" } + items[#items + 1] = { label = Strings("QUIT"), action = "quit" } + end + end + local ctx = { battle = self.battle, overworld = ow } + local hooked = Runtime.call("ui.party.submenu", sameItems, + self.game, items, mon, ctx) + if type(hooked) == "table" then + items = hooked + else + Logger.error("ui.party.submenu returned %s; keeping the vanilla list", + type(hooked)) + end + self.subItems = items + end +end + +function PartyMenu:cancel() + if self.submenu then self.submenu = nil; return end + self.game.stack:pop() + if self.onCancel then self.onCancel() end +end + function PartyMenu:update(dt) -- icon animation counter; 320 = a whole cycle at every HP speed self.blink = ((self.blink or 0) + 1) % 320 @@ -505,245 +898,9 @@ function PartyMenu:update(dt) elseif input:wasPressed("down") then self.subIndex = self.subIndex < n and self.subIndex + 1 or 1 elseif input:wasPressed("b") then - self.submenu = nil + self:cancel() elseif input:wasPressed("a") then - local mon = party[self.index] - local entry = self.subItems[self.subIndex] - local action = entry.action - if not action and entry.onSelect then - -- hook-injected entries carry a callback instead of an action id - entry.onSelect(mon, self.game) - elseif action == "stats" then - -- battle and field alike return to the party list afterwards - -- (core.asm .partyMenuWasSelected) - Screens.push(self.game, "SummaryMenu", mon, { - mons = party, - index = self.index, - onMonChange = function(index) self.index = index end, - }) - elseif action == "battle_switch" then - self.game.stack:pop() - self.onSwitch(mon) - return - elseif action == "cancel" then - self.game.stack:pop() - if self.onCancel then self.onCancel() end - return - elseif action == "switch" then - self.swapFrom = self.index - elseif action == "item" then - self:openItemMenu(mon) - elseif action == "quit" then - self.submenu = nil - elseif action == "fly" then - -- FLY opens the TOWN MAP with a cursor over the visited fly towns, - -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly). - -- flyTo (OverworldController) validates the fly-warp + runs the - -- departure/warp, so we just hand it the chosen mapId (#195). - local ow = self.game.overworld - self.game.stack:pop() -- close the party menu - Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) - if ow then ow:flyTo(mapId) end - end }) - return - elseif action == "flash" then -- FLASH lights dark tunnels - -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs - -- with the party menu still on screen, and only then - -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads - -- over the menu, and the cave is lit when the blink hands the - -- screen back, never under the text (#385). - local ow = self.game.overworld - local TextBox = require("src.render.TextBox") - local Transition = require("src.render.Transition") - -- The other half of CheckUseFlash: using it in the Aerodactyl chamber - -- opens the wall. The chamber's scene script plays the opening the - -- next time the map loads, exactly as the escape-rope one does. - require("src.script.RuinsOfAlph").aerodactylChamber(self.game) - self.game.save.flashLit = true - self.game.stack:push(TextBox.new(self.game, - self.game.data.text._FlashLightsAreaText - or Strings("A blinding FLASH\nlights the area!"), function() - self:close() - -- setDark, not a bare field write: ADVANCED carries the darkness - -- in a baked atlas, so lighting the cave drops every resident map - -- and rebakes this one (#383). It runs HERE, before the blink, - -- because start_sub_menus.asm .flash clears wMapPalOffset before - -- PrintText and blinks last of all: the cave is already lit by the - -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the - -- blink's completion instead left that rebuild's whole cost -- - -- seconds of per-pixel atlas baking on a phone -- on screen as a - -- solid white frame with nothing under it, which reads as a - -- lockup (#610). - ow:setDark(false) - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - return - elseif action == "surf" then - -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time - -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F - -- current refusals, both of which loop back to the submenu), then - -- ItemUseSurfboard: while surfing it tries to dismount instead; - -- otherwise it mounts only if the FACING tile is water, else - -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the - -- submenu. useSurfFieldMove reports which; trySurf does the mount. - local ow = self.game.overworld - local reason = ow:useSurfFieldMove() - local Transition = require("src.render.Transition") - if reason == "ok" then - -- UseItem prints _SurfingGotOnText with the party menu still up; - -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so - -- trySurf closes this menu when its text does (#385) - local fx, fy = ow.player:facingCell() - ow:trySurf(fx, fy, function() self:close() end) - return - end - if reason == "dismount" then - -- ItemUseSurfboard .stopSurfing: no text -- the walking state - -- and music return first (PlayDefaultMusic + - -- LoadWalkingPlayerSpriteGraphics), the menu closes with the - -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press - -- steps the player forward onto land (or across a connection - -- strip when the shore is the next map's edge) - self.game.stack:pop() - ow.player.surfing = false - require("src.core.Music").setSurfing(self.game.data, false) - self.game.stack:push(Transition.whiteFlash(self.game, nil, function() - ow:stepForwardOrCrossEdge(ow.player.facing) - end)) - return - end - local TextBox = require("src.render.TextBox") - local def = self.game.data.pokemon[mon.species] - local key = ({ no_badge = "_NewBadgeRequiredText", - forced_bike = "_CyclingIsFunText", - current = "_CurrentTooFastText", - no_place = "_SurfingNoPlaceToGetOffText" })[reason] - or "_NoSurfingHereText" - local txt = (self.game.data.text[key] or Strings("No SURFing here!")) - :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) - if reason == "no_place" then - -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but - -- never zeroes wActionResultOrTookBattleTurn, so unlike the - -- other refusals the menu still closes afterwards - -- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints - -- over the still-open menu like every other .loop refusal (#385) - self.game.stack:push(TextBox.new(self.game, txt, function() - self:close() - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - return - end - self.game.stack:push(TextBox.new(self.game, txt)) - return -- .loop: submenu stays open behind the message - elseif action == "cut" then - -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): - -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the - -- submenu when the FACING tile isn't a cuttable tree. - local ow = self.game.overworld - local reason = ow:useCutFieldMove() - if reason == "ok" then - self.game.stack:pop() -- close the party menu (CloseTextDisplay) - local fx, fy = ow.player:facingCell() - ow:tryCut(fx, fy) - return - end - local TextBox = require("src.render.TextBox") - local def = self.game.data.pokemon[mon.species] - local key = (reason == "no_badge") and "_NewBadgeRequiredText" - or "_NothingToCutText" - local txt = (self.game.data.text[key] or Strings("Nothing to CUT!")) - :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) - self.game.stack:push(TextBox.new(self.game, txt)) - return -- .loop: submenu stays open behind the message - elseif action == "strength" then - -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); - -- predef PrintStrengthText (field_move_messages.asm) sets - -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate - -- push_boulder.asm reads -- then prints _UseStrengthText (no - -- prompt: after the text, the text_asm tail plays the chosen - -- mon's cry, Delay3, and it auto-advances) and - -- _MoveBoulderText (`prompt`: waits for A/B). Back in - -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white - -- before CloseTextDisplay returns to the map. - local ow = self.game.overworld - local TextBox = require("src.render.TextBox") - local Transition = require("src.render.Transition") - local def = self.game.data.pokemon[mon.species] - local name = mon.nickname or (def and def.name) or mon.species - ow.strengthActive = true - -- Gen2 text keys are _UseStrengthText / _MoveBoulderText; - -- Gen1 uses _UsedStrengthText / _CanMoveBouldersText. Try both. - local t1raw = self.game.data.text._UseStrengthText - or self.game.data.text._UsedStrengthText - local t2raw = self.game.data.text._MoveBoulderText - or self.game.data.text._CanMoveBouldersText - local t1 = (t1raw or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) - local t2 = (t2raw or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name) - -- like surf (#320, #385): both texts print with the party menu - -- still on screen, and the blink IS the menu closing afterwards, - -- not a flashbang on the empty map - self.game.stack:push(TextBox.new(self.game, t1, function() - self.game.stack:push(TextBox.new(self.game, t2, function() - self:close() - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - end, { auto = { sound = function() - return require("src.core.Sound").playCry(self.game.data, mon.species) - end } })) - return - elseif action == "gen2_field" then - -- GSC runs the same handler from the menu as from the overworld - -- A press (SurfFromMenuScript / HeadbuttFromMenuScript), so the - -- tile check lives in one place. - local ow = self.game.overworld - local fx, fy = ow.player:facingCell() - self:close() - if not ow:gen2FieldMoveAt(entry.move, mon, fx, fy) then - local TextBox = require("src.render.TextBox") - -- _CantSurfText is "You can't SURF here", so using it for every - -- field move told a player holding HEADBUTT that the game thought - -- they had picked SURF. Only SURF gets the surf line. - local text = (entry.move == "SURF") - and self.game.data.text._CantSurfText or nil - self.game.stack:push(TextBox.new(self.game, - text or Strings("You can't use that\nhere."))) - end - return - elseif action == "softboiled" then - -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer - -- 1/5 of the user's max HP to a chosen teammate - self.softboiledFrom = self.index - elseif action == "dig" then - -- GEN 2 (.DoDig): back out through the recorded entrance, arriving as - -- a DOOR warp so the player steps out of the opening rather than - -- standing in it. GEN 1's DIG is ItemUseEscapeRope and shares - -- TELEPORT's Pokemon Center warp, so it falls through to the same - -- departure with no target. - local ow = self.game.overworld - self.game.stack:pop() - if ow then - local escape = require("src.core.GameVersion").isGen2() - and ow.escapePoint and ow:escapePoint() and true or nil - ow:beginTeleportOut(nil, { escape = escape }) - end - return - elseif action == "escape" then - -- TELEPORT warps to the last Pokémon Center TOWN (wLastBlackoutMap, - -- special_warps.asm escape warp) -- and Gen 2 agrees: TeleportFunction - -- .TryTeleport requires an OUTDOOR map and warps to wLastSpawnMapGroup - -- / wLastSpawnMapNumber, so this destination is right for both - -- generations. Only DIG diverged; it has its own branch above. - -- pokered's .dig/.teleport spin the - -- player up (LeaveMapAnim), white/fade out, then land it; this port - -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut - -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares - -- the exact departure; the fade + warp fire when the spin ends. - local ow = self.game.overworld - self.game.stack:pop() - if ow then ow:beginTeleportOut() end - return - end - self.submenu = nil + self:confirm() end return end @@ -753,150 +910,9 @@ function PartyMenu:update(dt) elseif input:wasPressed("down") then self.index = self.index < #party and self.index + 1 or 1 elseif input:wasPressed("b") then - self.game.stack:pop() - if self.onCancel then self.onCancel() end + self:cancel() elseif input:wasPressed("a") and #party > 0 then - local mon = party[self.index] - if self.softboiledFrom then - local user = party[self.softboiledFrom] - local heal = math.floor(user.stats.hp / 5) - if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp - or user.hp <= heal then - self.softboiledFrom = nil - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect."))) - else - user.hp = user.hp - heal - mon.hp = math.min(mon.stats.hp, mon.hp + heal) - self.softboiledFrom = nil - require("src.core.Sound").play(self.game.data, "Heal_HP") - local def = self.game.data.pokemon[mon.species] - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, - Strings("%s's HP\nwas restored!", mon.nickname or def.name))) - end - elseif self.swapFrom then - if self.swapFrom ~= self.index then - party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] - require("src.core.Sound").play(self.game.data, "Swap") - end - self.swapFrom = nil - elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then - -- keepOpen callers (HP medicine) need the menu still drawn while the - -- bar fills and the message prints, and close it themselves; everyone - -- else keeps the old pop-then-call order. Popping first is what made - -- a POTION snap the picker shut before the item had even run (#252). - if not self.keepOpen then self.game.stack:pop() end - self.onSwitch(mon, self) - else - self.submenu = true - self.subIndex = 1 - local items - local ow = self.game.overworld - if self.battle and self.onSwitch then - -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) - items = { { label = Strings("SWITCH"), action = "battle_switch" }, - { label = Strings("STATS"), action = "stats" }, - { label = Strings("CANCEL"), action = "cancel" } } - else - -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm - -- builds the same dynamic list) - items = { { label = Strings("STATS"), action = "stats" }, - { label = Strings("SWITCH"), action = "switch" } } - -- Field moves (HMs/TMs) are usable out of battle even when the mon - -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. - -- Battle still excludes this list via `not self.battle`. Softboiled - -- can appear for a fainted user; its heal transfer then no-ops. - if not self.battle and ow then - -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- - -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) - local outside = Map.isOutside(ow.map.def, - FieldDefaults.field(self.game.data, "outsideTilesets")) - -- The badge gate per field move comes from constants.hmBadges and - -- the badge itself through Badges.has: Gen2 gates the same moves on - -- the Johto badges and stores them as engine flags rather than bag - -- items, so the hard-coded R/B item names listed nothing at all. - local gates = FieldDefaults.constant(self.game.data, "hmBadges") or {} - local function badged(moveId) - local gate = gates[moveId] - if not (gate and gate.badge) then return true end - return Badges.has(self.game.save, { id = gate.badge }) - end - for _, mv in ipairs(mon.moves) do - if mv.id == "FLY" and outside and badged("FLY") then - table.insert(items, { label = Strings("FLY"), action = "fly" }) - -- LIGHT is carved on the Aerodactyl chamber wall, and the - -- chamber is not a dark cave -- so CheckUseFlash asks - -- SpecialAerodactylChamber FIRST and lets FLASH be used on its - -- carry, before it ever looks at the palset - -- (engine/events/overworld.asm:285). Without this the move is - -- never even offered there and the wall can never open. - elseif mv.id == "FLASH" and badged("FLASH") - and (ow.dark or (ow.map and ow.map.id - == require("src.script.RuinsOfAlph").MAPS.aerodactyl)) then - table.insert(items, { label = Strings("FLASH"), action = "flash" }) - elseif mv.id == "CUT" and badged("CUT") then - -- CUT/SURF/STRENGTH are party-menu field moves too - -- (start_sub_menus.asm .outOfBattleMovePointers); listed here - -- with the same list-time badge filter this file already uses - -- for FLY/FLASH. The facing-tile/activation check happens on - -- selection (useCutFieldMove/useSurfFieldMove). - table.insert(items, { label = Strings("CUT"), action = "cut" }) - elseif mv.id == "SURF" and badged("SURF") then - table.insert(items, { label = Strings("SURF"), action = "surf" }) - elseif mv.id == "STRENGTH" and badged("STRENGTH") then - table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) - elseif mv.id == "SOFTBOILED" or mv.id == "MILK_DRINK" then - table.insert(items, { label = Strings(mv.id:gsub("_", " ")), - action = "softboiled" }) - elseif mv.id == "TELEPORT" and outside then - -- TELEPORT works only OUTDOORS (start_sub_menus.asm - -- .teleport -> CheckIfInOutsideMap); dark maps don't - -- block it - table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) - elseif mv.id == "DIG" and ow.map.id ~= "AGATHAS_ROOM" - and (DIG_TILESETS[ow.map.def.tileset] - or (GEN2_DIG_ENVIRONMENTS[ow.map.def.environment] - and ow.escapePoint and ow:escapePoint())) then - -- GEN 1: DIG runs ItemUseEscapeRope (.dig sets wCurItem = - -- ESCAPE_ROPE): usable in the dungeon tilesets of - -- escape_rope_tilesets.asm minus Agatha's room, even in - -- the dark (Rock Tunnel). - -- GEN 2: .CheckCanDig instead -- CAVE or DUNGEON, and a - -- recorded entrance to come back out of. Its own action, - -- because Gen 2's DIG and TELEPORT no longer share a - -- destination the way Gen 1's did. - table.insert(items, { label = Strings("DIG"), action = "dig" }) - elseif GEN2_FIELD_MOVES[mv.id] and badged(mv.id) then - -- GSC's own field moves; the tile check happens on selection, - -- exactly like CUT's (start_sub_menus.asm never gates the list - -- on where the player is standing) - table.insert(items, { label = Strings(mv.id:gsub("_", " ")), - action = "gen2_field", move = mv.id }) - end - end - end - if require("src.core.GameVersion").isGen2() then - -- GSC's MonMenu lists the field moves first, then STATS, SWITCH, - -- ITEM and QUIT; Gen 1 has no held items and no QUIT row. - local stats, switch = table.remove(items, 1), table.remove(items, 1) - items[#items + 1] = stats - items[#items + 1] = switch - items[#items + 1] = { label = Strings("ITEM"), action = "item" } - items[#items + 1] = { label = Strings("QUIT"), action = "quit" } - end - end - local ctx = { battle = self.battle, overworld = ow } - local hooked = Runtime.call("ui.party.submenu", sameItems, - self.game, items, mon, ctx) - if type(hooked) == "table" then - items = hooked - else - Logger.error("ui.party.submenu returned %s; keeping the vanilla list", - type(hooked)) - end - self.subItems = items - end + self:confirm() end end diff --git a/src/ui/UICommands.lua b/src/ui/UICommands.lua new file mode 100644 index 000000000..437932a88 --- /dev/null +++ b/src/ui/UICommands.lua @@ -0,0 +1,346 @@ +-- Admission and dispatch for native party, bag and dialogue screens. +-- No screen is loaded here: an already loaded native class must own the top. +local Commands = {} +local CLASSES = { + PartyMenu = "party_gb", + Gen3PartyMenu = "party_gba", + Gen4PartyMenu = "party_ds", + Gen3BagMenu = "bag_gba", + Gen4BagMenu = "bag_ds", + HgssBagMenu = "bag_hgss", + Gen5BagMenu = "bag_bw", + Gen4BattleBag = "battle_bag", + Gen5BattleBag = "battle_bag", + Gen3ItemMenu = "item_actions", + ChoiceBox = "choice", +} +local function class(screen) + local mt = getmetatable(screen) + if mt == nil then + return + end + if mt == package.loaded["src.render.TextBox"] then + return "text" + end + for name, kind in pairs(CLASSES) do + if mt == package.loaded["src.ui." .. name] then + return kind + end + end + if mt == package.loaded["src.ui.ListMenu"] and screen.publicUIKind == "bag" then + return "bag_gb" + end + if mt == package.loaded["src.ui.Menu"] and screen.publicUIKind == "bag_actions" then + return "bag_actions" + end +end +local function row(out, value, ids, item) + local i = #out.rows + 1 + local r = { index = i, enabled = true } + if type(value) == "table" then + r.label = type(value.label) == "string" and value.label or nil + r.action = type(value.action) == "string" and value.action or nil + if value.header then + r.enabled = false + end + if value.close then + r.action = "back" + end + if item then + r.item = ids:read("items", value.id or value.value) + r.count = type(value.qty) == "number" and value.qty or nil + if not r.label and type(value.def) == "table" and type(value.def.name) == "string" then + r.label = value.def.name + end + end + elseif type(value) == "string" then + r.action, r.label = value, value + end + out.rows[i] = r + return r +end +function Commands.describe(game, screen, ids) + local kind = class(screen) + if not kind then + return nil, "unsupported UI screen" + end + if screen.game ~= game then + return nil, "UI does not belong to current Game" + end + local out = { + kind = "party", + rows = {}, + index = screen.index, + canBack = true, + canAdvance = false, + canPocket = false, + locked = false, + } + local refs = { screen = screen, stack = game.stack, states = game.stack.states, save = game.save } + -- Keep callback and nested option identity private; no engine references escape. + for key, value in pairs(screen) do + if type(value) == "function" or type(value) == "table" then + refs[key] = value + end + end + local function rows(values, items) + for i, value in ipairs(values or {}) do + refs["row:" .. i] = value + if type(value) == "table" then + refs["callback:" .. i] = value.onSelect + end + row(out, value, ids, items) + end + end + if kind:match("^party_") then + local party = kind == "party_gb" and (screen.party or game.save.party) or screen:party() + for i, mon in ipairs(party or {}) do + refs["mon:" .. i] = mon + end + if screen.selectNotice then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.submenu then + out.kind = "party_actions" + rows( + kind == "party_gb" and screen.subItems or kind == "party_gba" and screen.submenu or screen:actions() + ) + out.index = kind == "party_ds" and screen.submenu or screen.subIndex + else + for i, mon in ipairs(party or {}) do + refs["mon:" .. i] = mon + local r = row(out, {}, ids) + r.partyIndex, r.species, r.label = i, ids:read("pokemon", mon.species), mon.nickname + r.hp, r.maxHp = mon.hp, mon.stats and mon.stats.hp or mon.maxHp + end + if kind ~= "party_gb" then + if kind == "party_ds" and screen.select then + row(out, "confirm", ids) + end + row(out, "back", ids) + end + end + elseif kind == "text" then + out.kind, out.index, out.canBack = "text", nil, false + out.canAdvance = ( + screen.waiting or (screen.done and not screen.auto and not screen.stay and not screen.choice) + ) + and (screen.preWait or 0) <= 0 + and (screen.holdFrames or 0) <= 0 + or false + out.locked = not out.canAdvance + elseif kind == "choice" then + out.kind = "choice" + rows({ "yes", "no" }) + out.locked = screen.pending ~= nil + elseif kind == "battle_bag" then + out.kind, out.page = "bag", screen.screen + local mem = screen.mem or screen.cur + out.pocket = screen.pocket + out.index = screen.cursor and screen.cursor.index + if screen.message then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.screen == "menu" then + rows({ "pocket:1", "pocket:2", "pocket:3", "pocket:4", "last_item", "back" }) + out.rows[5].enabled = mem.lastItem ~= nil + elseif screen.screen == "pocket" then + for i = 1, 6 do + local item = screen:itemAt(i) + refs["row:" .. i] = item + local r = row(out, item or {}, ids, true) + r.enabled = item ~= nil + end + row(out, "previous_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0 + row(out, "next_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0 + row(out, "back", ids) + else + rows({ "use", "back" }) + out.rows[1].enabled = screen:itemAt((mem.positions[screen.pocket] or 0) + 1) ~= nil + end + out.locked = screen.press ~= nil + elseif kind == "bag_actions" or kind == "item_actions" then + out.kind = "bag_actions" + rows(kind == "bag_actions" and screen.items or screen.entries) + if kind == "item_actions" then + for i, r in ipairs(out.rows) do + r.enabled = screen:pickable(i) + r.action = screen.entries[i].kind + end + else + out.canBack = screen.cancelable + end + else + out.kind = "bag" + out.pocket = screen.pocket or screen.pocketIndex + if screen.message or screen.resultMessage then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.menu then + out.kind, out.index = screen.menu.kind == "yesno" and "choice" or "bag_actions", screen.menu.index + rows(screen.menu.rows) + else + rows(kind == "bag_gb" and screen.items or kind == "bag_hgss" and screen:items() or screen.rows, true) + out.index = kind == "bag_hgss" and screen.page * 6 + screen.slot + or kind == "bag_bw" and screen.cursor + or screen.index + out.canPocket = not screen.lockPocket + and (kind == "bag_gb" and screen.onPocketSwitch ~= nil or kind ~= "bag_gb" and #(screen.pockets or {}) > 1) + or false + end + out.locked = screen.trash ~= nil and not screen.menu + or screen.moving ~= nil and screen.moving ~= false + or false + end + local battle = screen.battle or screen.publicUIBattle + if + battle and (battle.demo or battle.dudeDemo or battle.pokedudeDemo or battle.spectating or battle.dead) + then + out.locked = true + end + if screen.heal or screen.noInput or screen.script or screen.closed then + out.locked = true + end + if out.locked then + out.canAdvance, out.canPocket, out.canBack = false, false, false + end + return out, refs, kind +end +local function press(screen, ds) + local Sound = require("src.core.Sound") + if ds then + if Sound.playSe and screen.game.data then + Sound.playSe(screen.game.data, "SEQ_SE_DP_SELECT") + end + elseif screen.game.data then + Sound.play(screen.game.data, "Press_AB") + end +end +function Commands.dispatch(screen, kind, view, intent) + if intent.kind == "advance" then + if kind == "text" then + screen:advance() + elseif screen.selectNotice then + screen.selectNotice = nil + else + if screen.resultMessage then + press(screen) + end + screen.message, screen.resultMessage = nil, nil + end + elseif kind == "text" then + error("text only supports advance") + elseif kind == "choice" then + screen:choose(intent.kind == "back" and 2 or intent.index) + elseif kind == "battle_bag" then + if intent.kind == "select" then + screen.cursor.index = intent.index + end + screen:pick(intent.kind == "back" and ({ menu = 6, pocket = 9, use = 2 })[screen.screen] or intent.index) + elseif kind == "party_gb" then + press(screen) + if intent.kind == "back" then + screen:cancel() + else + if screen.submenu then + screen.subIndex = intent.index + else + screen.index = intent.index + end + screen:confirm() + end + elseif kind == "party_gba" or kind == "party_ds" then + if intent.kind == "back" then + if screen.submenu then + if screen.itemMenu then + screen.itemMenu, screen.submenu = nil, 1 + else + screen.submenu = nil + end + else + screen:cancel() + end + elseif screen.submenu then + if kind == "party_ds" then + screen.submenu = intent.index + screen:runAction(screen:actions()[intent.index]) + else + screen.subIndex = intent.index + local r = screen.submenu[intent.index] + screen:runAction(r.action, screen:party()[screen.index], r) + end + else + screen.index = intent.index + if kind == "party_gba" then + screen:confirm() + else + screen:choose() + end + end + elseif kind == "bag_actions" then + if intent.kind == "back" then + screen:cancel() + else + screen.index = intent.index + screen:choose() + end + elseif kind == "item_actions" then + press(screen) + if intent.kind == "select" then + screen.index = intent.index + end + screen:close(intent.kind == "back" and "cancel" or screen.entries[intent.index].kind) + elseif screen.menu then + if intent.kind == "back" then + if kind == "bag_ds" then + press(screen, true) + end + if screen.menu.kind == "yesno" then + screen:resolveTrash(false) + else + screen.menu = nil + end + else + screen.menu.index = intent.index + if kind ~= "bag_ds" then + screen:runMenuRow() + else + press(screen, true) + if screen.menu.kind == "yesno" then + screen:resolveTrash(intent.index == 1) + else + screen:runAction(screen.menu.rows[intent.index]) + end + end + end + elseif intent.kind == "pocket" then + if kind == "bag_gb" then + screen.onPocketSwitch(screen, intent.direction) + elseif kind == "bag_hgss" or kind == "bag_bw" then + screen:setPocket((screen.pocket - 1 + intent.direction) % #screen.pockets + 1) + else + screen.pocketLit = nil + screen:movePocket(intent.direction) + end + elseif intent.kind == "back" then + if kind == "bag_gb" then + screen:cancel() + else + if kind ~= "bag_gba" then + press(screen, true) + end + screen:close() + end + else + if kind == "bag_hgss" then + screen.page, screen.slot = math.floor((intent.index - 1) / 6), (intent.index - 1) % 6 + 1 + elseif kind == "bag_bw" then + screen.cursor = intent.index + screen.top = math.max(0, intent.index - 6) + else + screen.index = intent.index + if kind == "bag_ds" then + screen:clampScroll() + end + end + screen:choose() + end +end +return Commands diff --git a/tests/engine/ui_actions.lua b/tests/engine/ui_actions.lua new file mode 100644 index 000000000..8103d644f --- /dev/null +++ b/tests/engine/ui_actions.lua @@ -0,0 +1,792 @@ +-- Real native screens, Loader facades and ROM-free generation fixtures. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local GameVersion = require("src.core.GameVersion") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local Intents = require("src.mods.BattleIntents") +local original = GameVersion.get() +local versions = GameVersion.ORDER +local function boot(version, apiLevel) + GameVersion.set(version) + local data = T.fixtures.fresh() + local files = {} + for _, id in ipairs({ "probe", "other" }) do + files["mods/" .. id .. "/manifest.json"] = + Json.encode({ id = id, name = id, version = "1.0.0", api = apiLevel or 2, entry = "main.lua" }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + assert(mod.ui:snapshot()==nil) + assert(type(mod.ui.insertBefore)=="function") + if mod.supports then + assert(mod:supports("ui", "snapshot")) + assert(mod:supports("ui", "submit")) + assert(not mod:supports("ui", "unknown")) + end + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + T.check(loader:load(data), "fixture mods load") + local game = { data = data, stack = { states = {} }, save = SaveData.newGame() } + loader.game = game + Font.load(data) + game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + function game.stack:top() + return self.states[#self.states] + end + function game.stack:push(state) + self.states[#self.states + 1] = state + end + function game.stack:pop() + return table.remove(self.states) + end + return loader.apis.probe.ui, game, loader.apis.other.ui, loader +end +local function button(game, screen, name) + local old = game.input + game.input = { + wasPressed = function(_, key) + return key == name + end, + isDown = function() + return false + end, + } + screen:update(1 / 60) + game.input = old +end +local function submit(ui, id, kind, fields) + local snap, err = ui:snapshot() + assert(snap, err) + local intent = { id = id, revision = snap.revision, kind = kind } + for k, v in pairs(fields or {}) do + intent[k] = v + end + return ui:submit(intent) +end +local partyClass = { + red = "PartyMenu", + crystal = "PartyMenu", + emerald = "Gen3PartyMenu", + platinum = "Gen4PartyMenu", + heartgold = "Gen4PartyMenu", + black = "Gen4PartyMenu", +} +local bagClass = { + red = "BagMenu", + crystal = "BagMenu", + emerald = "Gen3BagMenu", + platinum = "Gen4BagMenu", + heartgold = "HgssBagMenu", + black = "Gen5BagMenu", +} +for _, version in ipairs(versions) do + local gen = GameVersion.VERSIONS[version].generation + partyClass[version] = partyClass[version] + or (gen <= 2 and "PartyMenu" or gen == 3 and "Gen3PartyMenu" or "Gen4PartyMenu") + bagClass[version] = bagClass[version] + or ( + gen <= 2 and "BagMenu" + or gen == 3 and "Gen3BagMenu" + or version == "soulsilver" and "HgssBagMenu" + or gen == 5 and "Gen5BagMenu" + or "Gen4BagMenu" + ) + local ui, game, other = boot(version) + local chosen, cancelled = 0, 0 + local screen = require("src.ui." .. partyClass[version]).new(game, { + pickOnly = true, + onSwitch = function(mon) + T.eq(mon, game.save.party[2], "native picker target") + chosen = chosen + 1 + end, + onCancel = function() + cancelled = cancelled + 1 + end, + }) + game.stack:push(screen) + local snap = assert(ui:snapshot()) + T.eq(snap.kind, "party", version .. " party recognized") + T.eq(snap.rows[2].species, "FIXMON_B", "portable party ID") + T.eq(ui:snapshot().revision, snap.revision, "unchanged read retains revision") + snap.rows[1].hp = -999 + T.neq(game.save.party[1].hp, -999, "snapshot detached") + local stale = other:snapshot() + T.check(submit(ui, 1, "select", { index = 2 }), "native party selection accepted") + T.eq(chosen, 1, "picker callback once") + T.eq(#game.stack.states, 0, "picker popped") + T.eq( + other:submit({ id = 1, revision = stale.revision, kind = "back" }), + nil, + "other facade cannot use departed picker" + ) + game.stack:push(require("src.ui." .. partyClass[version]).new(game, { + onCancel = function() + cancelled = cancelled + 1 + end, + })) + T.check(submit(ui, 2, "back"), "native party back") + T.eq(cancelled, 1, "onCancel delivered") + local bag = require("src.ui." .. bagClass[version]).new(game, {}) + game.stack:push(bag) + local bagSnap = assert(ui:snapshot()) + T.eq(bagSnap.kind, "bag", version .. " bag recognized") + if bagSnap.canPocket then + T.check(submit(ui, 3, "pocket", { direction = 1 }), "native pocket switch") + end + T.check(submit(ui, 4, "back"), "native bag back") + T.eq(#game.stack.states, 0, "bag popped") +end + +-- Walk raw entries, preserving identity and cycles, to detect any gameplay +-- writes from observations and rejected requests (T.same is not cycle-safe). +local function freeze(root) + local graph = {} + local function visit(t) + if type(t) ~= "table" or graph[t] then + return + end + local entry = { mt = getmetatable(t), values = {} } + graph[t] = entry + for k, v in pairs(t) do + entry.values[k] = v + visit(k) + visit(v) + end + end + visit(root) + return function(label) + local same = true + for t, entry in pairs(graph) do + same = same and getmetatable(t) == entry.mt + for k, v in pairs(entry.values) do + same = same and rawget(t, k) == v + end + for k, v in pairs(t) do + same = same and entry.values[k] == v + end + end + T.check(same, label) + end +end +local function refusal(ui, game, intent, message) + local unchanged = freeze(game) + local ok, err = ui:submit(intent) + T.eq(ok, nil, message) + T.check(type(err) == "string", message .. " explains refusal") + unchanged(message .. " leaves game untouched") + return err +end +for _, version in ipairs(versions) do + -- Compare native A/B to the facade for each party submenu and its back path. + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + local party = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack:push(party) + local unchanged = freeze(game) + local snap = ui:snapshot() + unchanged("party snapshot is observational") + T.check(snap ~= nil, "native screen available") + if native then + button(game, party, "a") + else + T.check(submit(ui, 1, "select", { index = 1 }), "open party submenu") + end + local stableSubmenu = freeze(game) + snap = ui:snapshot() + stableSubmenu("submenu snapshot remains observational") + T.eq(snap.kind, "party_actions", "party action rows offered") + local swap + for i, r in ipairs(snap.rows) do + if r.action == "switch" or r.action == "switchOrder" then + swap = i + end + end + T.check(swap ~= nil, "native swap row exposed") + if native then + if GameVersion.VERSIONS[version].generation <= 3 then + party.subIndex = swap + else + party.submenu = swap + end + button(game, party, "a") + party.index = 2 + button(game, party, "a") + else + T.check(submit(ui, 2, "select", { index = swap }), "start swap") + T.check(submit(ui, 3, "select", { index = 2 }), "finish swap") + end + traces[#traces + 1] = { + first = game.save.party[1].species, + second = game.save.party[2].species, + depth = #game.stack.states, + index = party.index, + swapFrom = party.swapFrom, + switchFrom = party.switchFrom, + } + if native then + button(game, party, "b") + else + T.check(submit(ui, 4, "back"), "back after swap") + end + T.eq(#game.stack.states, 0, "native party cancellation") + end + T.same(traces[1], traces[2], version .. " swap matches native A/B") + + local ui, game, other, loader = boot(version) + local party = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack:push(party) + local snap = ui:snapshot() + for _, bad in ipairs({ + false, + 1, + "select", + {}, + { id = 1, revision = snap.revision, kind = "unknown" }, + { id = 1, revision = snap.revision, kind = "select", index = 0 }, + { id = 1, revision = snap.revision, kind = "select", index = 1.5 }, + { id = math.huge, revision = snap.revision, kind = "back" }, + { id = 1, revision = snap.revision, kind = "back", extra = true }, + setmetatable({ id = 1, revision = snap.revision, kind = "back" }, {}), + }) do + refusal(ui, game, bad, "invalid intent") + end + refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 999 }, "missing row") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "party cannot advance text") + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "pocket", direction = 1 }, + "party has no pockets" + ) + game.save.party[1].hp = game.save.party[1].hp - 1 + T.eq( + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "HP change stales snapshot"), + "stale UI context", + "stale reason" + ) + snap = ui:snapshot() + local replacement = {} + for k, v in pairs(game.save.party[1]) do + replacement[k] = v + end + game.save.party[1] = replacement + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "back" }, + "identical replacement mon stales snapshot" + ) + -- Different screen identity, even with identical values. + snap = ui:snapshot() + local replacementScreen = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack.states[1] = replacementScreen + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "replacement screen stales snapshot") + party = replacementScreen + -- Whitelist native ownership; never infer an unrelated menu from its rows. + game.stack:push({ game = game, kind = "party", items = { { label = "fake" } } }) + T.eq(ui:snapshot(), nil, "unrelated overlay unsupported") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "covered native menu refused") + game.stack:pop() + for _, flag in ipairs({ "heal", "noInput", "script", "closed" }) do + party[flag] = true + snap = ui:snapshot() + T.eq(snap.locked, true, flag .. " locks public UI") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, flag .. " blocks action") + party[flag] = nil + end + -- Shared battle/UI guard, cross-mod epoch, consumed IDs and partial failures. + snap = ui:snapshot() + Intents.state(game).busy = true + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "in-progress battle/UI work blocked") + T.eq(ui:snapshot().locked, true, "busy snapshot locked") + Intents.state(game).busy = false + snap = ui:snapshot() + Intents.state(game).epoch = Intents.state(game).epoch + 1 + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle work expires UI snapshot") + local calls, during, observed = 0 + party.onCancel = function() + calls = calls + 1 + game.stack:push(party) -- keep an identical screen for the epoch test + during = other:snapshot() + observed = other:submit({ id = 9, revision = during.revision, kind = "back" }) + error("native callback failure") + end + local otherSnap = other:snapshot() + snap = ui:snapshot() + Runtime.currentMod = "probe" + local ok, err = ui:submit({ id = 10, revision = snap.revision, kind = "back" }) + T.eq(ok, nil, "partial native failure reported") + T.check(err:find("intent consumed", 1, true) ~= nil, "partial failure consumes id") + T.eq(Runtime.currentMod, "probe", "engine attribution restored after error") + Runtime.currentMod = nil + T.eq(calls, 1, "callback invoked once") + T.eq(observed, nil, "callback cannot submit reentrantly") + T.eq(during.locked, true, "snapshot during callback is locked") + refusal(other, game, { id = 9, revision = otherSnap.revision, kind = "back" }, "other mod snapshot expired") + refusal(other, game, { id = 9, revision = during.revision, kind = "back" }, "mid-callback snapshot expired") + refusal( + ui, + game, + { id = 10, revision = ui:snapshot().revision, kind = "back" }, + "failed native action cannot replay" + ) + game.stack.states = { require("src.ui." .. partyClass[version]).new(game, {}) } + T.check(submit(ui, 11, "back"), "guard released after failure") + T.eq(loader.apis.probe.ui.insertBefore, require("src.ui.ModUI").insertBefore, "builder helpers retained") + T.neq(ui, other, "per-mod facade identity") +end + +local TextBox = require("src.render.TextBox") +local ChoiceBox = require("src.ui.ChoiceBox") +local function tickUntil(game, box, predicate) + for _ = 1, 1000 do + if predicate() then + return + end + box:update(1 / 60) + end + error("native UI did not reach expected state") +end +for _, version in ipairs(versions) do + local ui, game = boot(version) + local done = 0 + local box = TextBox.new(game, "First\fSecond", function() + done = done + 1 + end) + game.stack:push(box) + local snap = ui:snapshot() + T.eq(snap.kind, "text", "text recognized") + T.eq(snap.locked, true, "typing text locked") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "typing cannot be skipped") + tickUntil(game, box, function() + return box.waiting and (box.preWait or 0) <= 0 + end) + T.check(submit(ui, 1, "advance"), "manual page advances") + T.check((box.holdFrames or 0) > 0, "page clear hold preserved") + snap = ui:snapshot() + refusal(ui, game, { id = 2, revision = snap.revision, kind = "advance" }, "page hold cannot be skipped") + tickUntil(game, box, function() + return box.done + end) + T.check(submit(ui, 2, "advance"), "final text closes") + T.eq(done, 1, "text callback once") + T.eq(game.stack:top(), nil, "text popped") + for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do + box = TextBox.new(game, "Ready", nil, opts) + game.stack:push(box) + box.done = true + snap = ui:snapshot() + T.eq(snap.canAdvance, false, "automatic/stay/choice text not manually dismissed") + refusal(ui, game, { id = 3, revision = snap.revision, kind = "advance" }, "script-owned text protected") + game.stack:pop() + end + -- Native text -> choice -> delayed answer -> both boxes popped. + local answers = {} + box = TextBox.new(game, "Question?", nil, { + defaultNo = true, + choice = function(yes) + answers[#answers + 1] = yes + end, + }) + game.stack:push(box) + tickUntil(game, box, function() + return box.done + end) + box:update(1 / 60) + local choice = game.stack:top() + T.eq(getmetatable(choice), ChoiceBox, "native choice appears") + T.eq(ui:snapshot().index, 2, "default NO retained") + T.check(submit(ui, 4, "select", { index = 1 }), "YES accepted") + T.eq(#answers, 0, "answer callback waits for native hold") + T.eq(#game.stack.states, 2, "both boxes retained during answer hold") + snap = ui:snapshot() + refusal(ui, game, { id = 5, revision = snap.revision, kind = "back" }, "pending answer locked") + tickUntil(game, choice, function() + return #answers == 1 + end) + T.eq(answers[1], true, "YES delivered") + T.eq(#game.stack.states, 0, "choice closes original text too") + choice = ChoiceBox.new(game, function(yes) + answers[#answers + 1] = yes + end) + game.stack:push(choice) + T.check(submit(ui, 5, "back"), "B selects NO") + T.eq(choice.index, 2, "NO cursor retained while pending") + tickUntil(game, choice, function() + return #answers == 2 + end) + T.eq(answers[2], false, "NO delivered") +end + +-- Pick an actual item through each native bag. API and physical A/B must +-- deliver the same callback and row, including HGSS pages and BW cursors. +for _, version in ipairs(versions) do + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + game.data.items.POTION = { + id = "POTION", + name = "POTION", + pocket = "ITEM", + fieldPocket = 0, + bwPocket = 0, + fieldUse = 1, + fieldUseFunc = 1, + battlePocket = 4, + } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local picked = {} + local bag = require("src.ui." .. bagClass[version]).new(game, { + pick = true, + onPick = function(id) + picked[#picked + 1] = id + end, + }) + game.stack:push(bag) + local unchanged = freeze(game) + local snap = ui:snapshot() + unchanged("bag snapshot does not rebuild, sort or load graphics") + local itemRow + for i, row in ipairs(snap.rows) do + if row.item == "POTION" then + itemRow = i + end + end + T.check(itemRow ~= nil, version .. " item exposed") + if native then + if bagClass[version] == "HgssBagMenu" then + bag.page, bag.slot = math.floor((itemRow - 1) / 6), (itemRow - 1) % 6 + 1 + elseif bagClass[version] == "Gen5BagMenu" then + bag.cursor = itemRow + else + bag.index = itemRow + if bag.clampScroll then + bag:clampScroll() + end + end + button(game, bag, "a") + else + T.check(submit(ui, 1, "select", { index = itemRow }), "native bag selection accepted") + end + -- GB's bag has no generic pick option: it opens USE/GIVE/TOSS instead. + local top = game.stack:top() + if bagClass[version] == "BagMenu" then + T.eq(ui:snapshot().kind, "bag_actions", "only factory-owned bag action menus offered") + if native then + button(game, top, "b") + else + T.check(submit(ui, 2, "back"), "back from native item submenu") + end + else + T.same(picked, { "POTION" }, "native pick callback exactly once") + end + traces[#traces + 1] = { picked = picked, count = game.save.inventory.POTION, depth = #game.stack.states } + end + T.same(traces[1], traces[2], version .. " bag confirm matches native input") +end +-- Real medicine effect, target picker and HP animation through the public +-- boundary. The native flow owns consumption and its delayed completion. +for _, version in ipairs({ "red", "crystal", "emerald" }) do + local ui, game = boot(version) + game.data.items.POTION = { id = "POTION", name = "POTION", pocket = "ITEM" } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local mon = game.save.party[1] + mon.hp = mon.stats.hp - 25 + local before = mon.hp + local bag = require("src.ui." .. bagClass[version]).new(game, {}) + game.stack:push(bag) + local snap = ui:snapshot() + local slot + for i, r in ipairs(snap.rows) do + if r.item == "POTION" then + slot = i + end + end + T.check(submit(ui, 1, "select", { index = slot }), "select medicine") + snap = ui:snapshot() + if snap.kind == "bag_actions" then + local use + for i, r in ipairs(snap.rows) do + if r.action == "use" or r.label == "USE" then + use = i + end + end + T.check(submit(ui, 2, "select", { index = use }), "native USE action") + end + local picker = game.stack:top() + T.eq(ui:snapshot().kind, "party", "medicine opens native target picker") + T.check(submit(ui, 3, "select", { index = 1 }), "select medicine target") + T.eq(mon.hp, before + 20, "real native potion restores 20 HP") + T.eq(game.save.inventory.POTION, 1, "real native potion consumes once") + T.eq(picker.heal ~= nil, true, "native heal animation retained") + snap = ui:snapshot() + refusal( + ui, + game, + { id = 4, revision = snap.revision, kind = "select", index = 1 }, + "cannot use again during HP fill" + ) + tickUntil(game, picker, function() + return picker.heal == nil + end) + local box = game.stack:top() + T.eq(getmetatable(box), TextBox, "native result text appears after fill") + tickUntil(game, box, function() + return box.done + end) + T.check(submit(ui, 4, "advance"), "native medicine result dismissed") + T.eq(game.save.inventory.POTION, 1, "result dismissal cannot consume again") +end +-- The GBA action grid keeps its blank cells and script input lock. +do + local ui, game = boot("emerald") + local chosen + local menu = require("src.ui.Gen3ItemMenu").new(game, { + entries = { + { label = "USE", kind = "use" }, + { label = "", kind = "blank" }, + { label = "CANCEL", kind = "cancel" }, + }, + onPick = function(kind) + chosen = kind + end, + }) + game.stack:push(menu) + local snap = ui:snapshot() + T.eq(snap.rows[2].enabled, false, "blank grid cell disabled") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 2 }, "blank cell refused") + T.check(submit(ui, 1, "select", { index = 1 }), "native item grid confirm") + T.eq(chosen, "use", "native item action callback") +end +-- DS battle bags admit their native delayed press, never call its callback +-- early. Page slots, absent items and return buttons preserve native indices. +for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + game.data.items.POTION = + { id = "POTION", name = "POTION", fieldPocket = 0, battlePocket = 4, battleMask = 4 } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local ds = GameVersion.VERSIONS[version].generation == 5 and "Gen5BattleBag" or "Gen4BattleBag" + local bag = require("src.ui." .. ds).new(game, {}) + game.gen4CursorActive = true + game.stack:push(bag) + local snap = ui:snapshot() + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "select", index = 5 }, + "unavailable last-used row" + ) + local function select(id, index) + if native then + bag.cursor.index = index + button(game, bag, "a") + else + T.check(submit(ui, id, "select", { index = index }), "battle bag native press admitted") + end + T.check(bag.press ~= nil, "native press pending") + T.eq(ui:snapshot().locked, true, "pending press locks UI") + tickUntil(game, bag, function() + return bag.press == nil + end) + end + select(1, 1) + T.eq(bag.screen, "pocket", "native pocket opens after animation") + snap = ui:snapshot() + T.eq(snap.rows[1].item, "POTION", "native battle item row") + refusal( + ui, + game, + { id = 2, revision = snap.revision, kind = "select", index = 6 }, + "empty item slot refused" + ) + select(2, 1) + T.eq(bag.screen, "use", "native use question") + select(3, 2) + T.eq(bag.screen, "pocket", "use back returns to pocket") + select(4, 9) + T.eq(bag.screen, "menu", "pocket back returns to categories") + if native then + button(game, bag, "b") + else + T.check(submit(ui, 5, "back"), "battle bag cancel") + end + T.check(bag.press ~= nil, "cancel animation pending") + tickUntil(game, bag, function() + return bag.press == nil + end) + traces[#traces + 1] = { + depth = #game.stack.states, + count = game.save.inventory.POTION, + page = bag.screen, + closed = bag.closed, + } + end + T.same(traces[1], traces[2], version .. " battle bag matches native A/B") +end + +-- opts.auto/stay/choice govern the final page, not the manual paragraph +-- breaks before it. Do not strand a question that spans several pages. +for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do + local ui, game = boot("crystal") + local box = TextBox.new(game, "First\fLast", nil, opts) + game.stack:push(box) + tickUntil(game, box, function() + return box.waiting and (box.preWait or 0) <= 0 + end) + T.eq(ui:snapshot().canAdvance, true, "script tail still allows manual intermediate page") + T.check(submit(ui, 1, "advance"), "intermediate question/automatic/stay page advances") + tickUntil(game, box, function() + return box.done + end) + T.eq(ui:snapshot().canAdvance, false, "final script-owned page stays protected") +end +-- Wrong Game, unknown overlays, script-owned bags and pending controllers. +do + local ui, game = boot("platinum") + local bag = require("src.ui.Gen4BagMenu").new(game, {}) + game.stack:push(bag) + for _, key in ipairs({ "moving", "trash", "script", "noInput" }) do + bag[key] = true + local snap = ui:snapshot() + T.eq(snap.locked, true, "busy bag locked") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "busy bag refusal") + bag[key] = nil + end + bag.battle = { demo = true } + local snap = ui:snapshot() + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle tutorial owns bag") + bag.battle = nil + bag.game = {} + T.eq(ui:snapshot(), nil, "wrong game screen unavailable") +end +-- Existing helpers remain usable with their original dot-call contract. +do + local ui, _, other = boot("red") + local rows = { { label = "a" }, { label = "c" } } + ui.insertBefore(rows, "c", { label = "b" }) + T.eq(rows[2].label, "b", "legacy builder still works") + T.eq(rawget(other, "snapshot") ~= nil, true, "each mod owns its facade methods") +end + +-- API 1 gets the same additive surface without changing its content format. +for _, version in ipairs({ "red", "emerald", "black" }) do + local ui, game, _, loader = boot(version, 1) + game.stack:push(ChoiceBox.new(game, function() end)) + T.eq(ui:snapshot().kind, "choice", "API 1 UI reader") + T.check(submit(ui, 1, "back"), "API 1 UI action") + if loader.apis.probe.supports then + T.eq(loader.apis.probe:supports("ui", "snapshot"), true, "optional reader capability") + T.eq(loader.apis.probe:supports("ui", "submit"), true, "optional action capability") + T.eq(loader.apis.probe:supports("ui", "unknown"), false, "unknown capability declined") + end +end +-- A real battle opens its picker via mod.battle:submit. Targeting the picker +-- consumes the current UI context, then native callbacks resume the battle. +do + local ui, game, _, loader = boot("crystal") + local BattleState = require("src.battle.BattleState") + local b = BattleState.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.queue, b.menuIndex = "menu", {}, 1 + game.stack:push(b) + local api = loader.apis.probe.battle + local snapshot = api:snapshot() + T.check( + api:submit({ id = 1, revision = snapshot.revision, kind = "menu", choice = "party" }), + "battle opens native party UI" + ) + for _ = 1, 100 do + if game.stack:top() ~= b then + break + end + b:updateQueue() + end + T.eq(ui:snapshot().kind, "party", "battle picker exposed by UI facade") + local oldBattle = api:snapshot() + T.check(submit(ui, 1, "select", { index = 2 }), "choose switching party slot") + T.eq(ui:snapshot().kind, "party_actions", "native battle switch submenu") + local switch + for i, row in ipairs(ui:snapshot().rows) do + if row.action == "battle_switch" then + switch = i + end + end + T.check(submit(ui, 2, "select", { index = switch }), "native switch callback dispatched") + T.eq(game.stack:top(), b, "native battle resumes") + T.check(#b.queue > 0, "native switch queues battle work") + T.eq( + api:submit({ id = 2, revision = oldBattle.revision, kind = "menu", choice = "fight" }), + nil, + "UI work expires battle context" + ) +end + +-- Link/temporary party arrays can differ from save.party. Track the actual +-- native target, including changes made while its action submenu is open. +do + local ui, game = boot("crystal") + local copy = { Pokemon.new(game.data, "FIXMON_C", 15), Pokemon.new(game.data, "FIXMON_B", 12) } + local party = require("src.ui.PartyMenu").new(game, { party = copy }) + game.stack:push(party) + T.check(submit(ui, 1, "select", { index = 1 }), "open temporary party submenu") + local snapshot = ui:snapshot() + copy[1].hp = copy[1].hp - 1 + T.eq( + refusal( + ui, + game, + { id = 2, revision = snapshot.revision, kind = "back" }, + "temporary party HP invalidates submenu" + ), + "stale UI context", + "actual native party tracked" + ) + snapshot = ui:snapshot() + local replacement = {} + for k, v in pairs(copy[1]) do + replacement[k] = v + end + copy[1] = replacement + refusal( + ui, + game, + { id = 2, revision = snapshot.revision, kind = "back" }, + "temporary party object identity tracked" + ) +end +GameVersion.set(original) +T.finish("UI actions") From 4bfa6683fc334697ec2622895a3678e4e4b8d525 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:15:11 +0200 Subject: [PATCH 10/71] Add opt-in prevalidated portable world dialogue scripts --- docs/modding.md | 7 + docs/portable-world-scripts.md | 164 +++++ src/mods/Loader.lua | 8 +- src/mods/Manifest.lua | 5 + src/mods/PortableScripts.lua | 543 +++++++++++++++ src/mods/ScriptProgram.lua | 160 +++++ tests/engine/portable_world_scripts.lua | 856 ++++++++++++++++++++++++ 7 files changed, 1741 insertions(+), 2 deletions(-) create mode 100644 docs/portable-world-scripts.md create mode 100644 src/mods/PortableScripts.lua create mode 100644 src/mods/ScriptProgram.lua create mode 100644 tests/engine/portable_world_scripts.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..57f514c97 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -87,6 +87,13 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the fields that moved; a new map becomes a `:register`. See `docs/new-features.md` and the extension's own README. +## Portable dialogue scripts (opt-in) + +API-2 mods can select `"script_format": "unified"` for prevalidated, copied +`mod.world:queueScript()` dialogue/control-flow programs with exactly-once +completion and owner-only cancellation. Existing manifests keep native script +behavior. See [portable commands, ownership and limits](portable-world-scripts.md). + ## Rendering pipelines Most registries hand the engine *content*. `render_pipelines` hands it diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md new file mode 100644 index 000000000..1391146ba --- /dev/null +++ b/docs/portable-world-scripts.md @@ -0,0 +1,164 @@ +# Portable dialogue scripts + +API-2 mods can opt into a bounded, prevalidated language for +`mod.world:queueScript(rows, options)`: + +```json +{ + "id": "quest_dialogue", + "name": "Quest dialogue", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "script_format": "unified" +} +``` + +Check `mod.scriptFormat == "unified"` in the entry file. Older engines may +ignore unknown manifest fields; checking the marker prevents a portable row +list from silently reaching their native script runner. Omitted or explicit +`"native"` formats preserve existing API-1/API-2 queueScript behavior. Other +values, or API 1 with `"unified"`, are load errors. Content and callback format +opt-ins are independent. The engine's Mod API major remains 2. + +## Contract + +```lua +return function(mod) + assert(mod.scriptFormat == "unified", "portable scripts required") + + -- Run this from an idle-world interaction/update, not at entry time. + local function offerQuest() + local ok, why = mod.world:queueScript({ + { "ask", "Help with the quest, {PLAYER}?" }, + { "jump_if_false", "declined" }, + { "show_text", "Thank you!" }, + { "jump", "end" }, + { "label", "declined" }, + { "text", "Maybe another time." }, + }, { + onDone = function(completed, reason) + if not completed then mod.log:warn("Dialogue stopped: %s", reason) end + end, + }) + if not ok then mod.log:warn("Dialogue not started: %s", why) end + end +end +``` + +Admission returns `true`, or `nil, reason` without executing any prefix of a +rejected program. The entire list, every row/argument and every jump target is +validated and copied first, including unreachable rows. Caller edits to the +original list, choice labels/options or callback options cannot change an +accepted program. Reading the facade and an early request do not construct +Game. A request with no live save/world fails quietly. + +`true` means admitted, not successfully completed. After admission, +`onDone(true)` reports completion and `onDone(false, reason)` reports a runtime +failure or cancellation. It fires exactly once, including failures during the +initial dispatch. It is not called for rejected requests. A throwing completion +callback is isolated and attributed to its mod; completion is not retried. +Already entered native work is not rolled back or automatically replayed. + +Only `onDone` is accepted in options. Programs and rows must be dense plain +arrays without metatables or extra keys. Functions/userdata/native handles are +not command arguments. Choice options are a closed plain table. + +## Initial command set + +| Row | Meaning | +| --- | --- | +| `{ "show_text", text }` / `{ "text", text }` | Native manual TextBox; wait for all pages and final dismissal | +| `{ "ask", text }` | Native text and YES/NO overlay; YES is true, NO/B is false | +| `{ "choice", labels, opts }` | Native N-way menu; choice 1 is true, every other choice is false | +| `{ "wait", frames }` | Native logic-frame wait, integer 0–36000; zero waits until the next world update | +| `{ "label", name }` | Named jump destination | +| `{ "jump", target }` | Unconditional jump | +| `{ "jump_if_true", target }` | Jump after a true answer | +| `{ "jump_if_false", target }` | Jump after false/nil, including before any answer | +| `{ "end" }` | Finish the program | + +Text is literal UTF-8, up to 4096 bytes per row, without NUL. Native TextBox +placeholder substitution (e.g. `{PLAYER}`), prose pagination, typing, scroll/ +page holds and answer holds remain in use. These rows do not resolve native +text registry IDs or lower ROM script bytecode. Future pages are not skipped. + +`labels` contains 1–16 nonempty UTF-8 strings of at most 128 bytes each. +Optional `opts.default` and `opts.cancel` are valid 1-based indices; defaults +are 1 and the last choice. B follows `cancel`. Answers remain internal to the +program for conditional jumps; the completion callback reports success/failure. + +Labels contain letters, digits, `_`, `-` or `:`, at most 64 bytes, and are unique. +`end` is reserved. Jump targets are existing labels, valid 1-based row indices, +or the string `"end"`. No arbitrary native verbs, custom command handlers, +extra callback arguments or executable rows are accepted. + +A program has at most 1024 rows and 128 KiB of text/choice labels. Execution +yields after each slice of 128 instructions and fails after 65536 instructions +in total. This bounds authored jump loops without a Lua debug hook or a frame +stall; it is not a wall-clock deadline on a player reading text. + +## Ownership and cancellation + +Admission requires the current overworld to own the top of the stack. Native +scripts, queued/parallel scripts, movement, transitions, field animations, +input/controller locks, link sessions and another portable program decline. +Only one portable foreground program can own a Game at a time. + +An accepted program temporarily installs an isolated instance of the native +ScriptRunner. Its `isRunning()` participates in the world's existing input and +script-scheduling gates. It uses native TextBox, ChoiceBox and Menu classes, +including their normal keyboard/touch handling and delayed answer completion. +The idle runner is restored at completion/cancellation, provided it has not +already been replaced by another controller. Global runner/UI classes and the +native command registry are unchanged. + +`mod.world:cancelScript()` cancels only this facade's active portable program. +It returns `true`, or `false, "no portable script"`. It cannot cancel another +mod or a native story script. Cancellation removes only the run's exact native +windows, even beneath a foreign overlay, and releases its coroutine. It does +not pop unrelated screens or release another controller's locks. A covering +foreign screen pauses normal progress. Removing an awaited owned window +externally cancels at the next input-step boundary. + +A replaced Game/save/data/stack/world/map/player/runner, changed map identity or +player position/version, transition, link session, mod rollback, reload or +teardown cancels the program. Session changes are checked at input-step and UI/ +runner boundaries, including while the world is covered. Retained callbacks +cannot resume a cancelled program, skip a later row's wait or pop a new screen. +Facades from an unloaded/reloaded/failed mod are retired. + +Native `script.started`/`script.ended` lifecycle events retain their context +shape; `source.modId` attributes the portable run and `completed` describes its +outcome. End notification/completion follow owned cleanup. Portable rows use +this validated language rather than resolving native/custom handlers or firing +`script.command` wrappers; native-format scripts retain those contracts. +Direct changes to engine objects from such event handlers or other mods remain +outside this boundary's guarantees; this is not a mod security sandbox. + +When optional capability queries are installed, unified mods can probe +`mod:supports("scripts", verb)` and `mod:supports("world", "cancelScript")`. +Unknown/unimplemented portable verbs return false. The existing native +`world/queueScript` capability is not, by itself, evidence of this opt-in. + +## Scope and verification + +This first adapter covers dialogue/control flow across the 15 supported +version selections (Gen 1–5). Flags, items/Pokémon, world movement/warps, +battles, shops and other Gen1Recomp portable verbs need separately verified +adapters; they refuse the complete list before the first effect here. + +The contract is informed by Gen1Recomp's +[UnifiedScripts](https://github.com/bryanthaboi/gen1recomp/blob/79ea62f45fb2ea8f65d081a109431e4eb1bbdb36/src/mods/UnifiedScripts.lua). +This implementation uses this host's native shared UI and runner architecture. + +Run `luajit tests/engine/portable_world_scripts.lua` and +`LUA=luajit bash scripts/test.sh`. The ROM-free suite uses the real Loader, +ScriptRunner, state stack and native UI classes. It compares complete per-frame +native/portable dialogue traces, checks preflight/argument copying, rejection +immutability, identity/session changes, replay, cancellation/reentry, budget and +native failures. Fixture runs do not establish rendered retail-ROM coverage. + +When the unified content/callback PRs are also present, run +`PORTABLE_SCRIPT_ALL_FORMATS=1 luajit tests/engine/portable_world_scripts.lua` +to repeat the suite with all three format opt-ins enabled together. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..d1d9d5e2b 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -11,6 +11,7 @@ local Data = require("src.core.Data") local Version = require("src.core.Version") local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") +local PortableScripts = require("src.mods.PortableScripts") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") @@ -1125,7 +1126,7 @@ function Loader:_api(mod) -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. local world, storage - setmetatable(api, { __index = function(_, key) + setmetatable(api, PortableScripts.wrap({ __index = function(_, key) if key == "game" then return loader:_game() end if key == "storage" then -- Do not pass loader.fs when it is love.filesystem: that always writes @@ -1141,7 +1142,7 @@ function Loader:_api(mod) if not module then return nil end world = module.new(game, modId) return world - end }) + end }, api, loader, mod.manifest)) return api end @@ -1196,6 +1197,7 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + PortableScripts.cancelAll(self, modId, "mod rolled back") for name in pairs(self.journal or self.content) do self.content[name]:rollback(modId) end @@ -1310,6 +1312,7 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + PortableScripts.cancelAll(nil, nil, "mods reloaded") -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- again (and the launcher backdrop boots the plain game in between); the @@ -1653,6 +1656,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + PortableScripts.cancelAll(nil, nil, "mods unloaded") local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..61ae1c60e 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -240,6 +240,10 @@ function Manifest.validate(raw, path) end end + local scriptFormat = raw.script_format == nil and "native" or raw.script_format + assert(scriptFormat == "native" or scriptFormat == "unified", "script_format must be native or unified") + assert(scriptFormat ~= "unified" or api >= 2, "unified scripts require mod API 2") + local gameVersionOk, gameVersionErr = Semver.validRange(raw.game_version) assert(gameVersionOk, ("malformed game_version %q: %s") :format(tostring(raw.game_version), tostring(gameVersionErr))) @@ -325,6 +329,7 @@ function Manifest.validate(raw, path) affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, + script_format = scriptFormat, options_schema = optionalFile(raw.options_schema, "options_schema"), -- Base files the mod needs but cannot ship -- a Stadium 2 cartridge, a -- second game's ROM. Declared here, satisfied by the launcher, read by diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua new file mode 100644 index 000000000..bfa6c6181 --- /dev/null +++ b/src/mods/PortableScripts.lua @@ -0,0 +1,543 @@ +-- Per-mod portable dialogue programs. An isolated native runner owns the +-- foreground until completion, then returns the idle runner it borrowed. +local Program = require("src.mods.ScriptProgram") +local Runtime = require("src.mods.Runtime") +local GameVersion = require("src.core.GameVersion") +local Scripts = {} +local runs = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local contexts = setmetatable({}, { __mode = "k" }) +local draining = false +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function engine(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local result = pack(pcall(fn, ...)) + Runtime.currentMod = owner + return unpack(result, 1, result.n) +end +local function current(loader) + if loader.game then + return loader.game + end + local game = package.loaded["src.core.Game"] + return type(game) == "table" and game or nil +end +local function message(err) + local ok, text = pcall(tostring, err) + return ok and text or "unprintable error" +end +local function contains(stack, state) + if type(stack.states) ~= "table" then + return false + end + for _, value in ipairs(stack.states) do + if value == state then + return true + end + end + return false +end +local function remove(stack, state) + if type(stack.states) ~= "table" then + return + end + for i = #stack.states, 1, -1 do + if stack.states[i] == state then + table.remove(stack.states, i) + if Runtime.wants("screen.popped") then + Runtime.emit("screen.popped", { state = state }) + end + return + end + end +end +local LOCKS = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", +} +local function ready(game) + if not (game and game.save) then + return nil, "no save" + end + local stack = game.stack + if not (stack and type(stack.states) == "table" and type(stack.top) == "function") then + return nil, "no overworld" + end + local ow = stack:top() + if not (ow and ow.isOverworld and ow.map and ow.player and ow.runner) then + return nil, "world is busy" + end + if + runs[game] + or game.overworld and game.overworld ~= ow + or game.linkSession + or game.linkNet and not game.linkNet.closed + then + return nil, "world is busy" + end + for _, key in ipairs(LOCKS) do + if ow[key] then + return nil, "world is busy" + end + end + for _, key in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + if ow.player[key] then + return nil, "world is busy" + end + end + if + ow.runner:isRunning() + or #(ow.scriptMoves or {}) > 0 + or #(ow.pendingScripts or {}) > 0 + or #(ow.parallelRunners or {}) > 0 + or #(ow.parallelQueue or {}) > 0 + then + return nil, "world is busy" + end + return ow +end +local function observe(loader) + local installed = observers[loader] + if installed and installed[1] == loader.hooks then + return + end + -- Weak values too: the hook closure captures its loader, so retaining the + -- hooks here would otherwise keep every unloaded loader alive on LuaJIT. + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + -- Keep one observer per loader: retiring a hook while its chain dispatches + -- would shift the following mod's index. Per-run cleanup never edits it. + loader.hooks:wrap("input.step", function(nextFn, game, dt) + if next(runs) == nil then + return nextFn(game, dt) + end + local pending = {} + for _, token in pairs(runs) do + if token.loader == loader then + pending[#pending + 1] = token + end + end + for _, token in ipairs(pending) do + local ok, live, why = engine(token.valid) + if not ok or not live then + token.finish(false, ok and why or "script context failed") + elseif token.waitingUI and not contains(token.stack, token.waitingUI) then + token.finish(false, "script UI was removed") + end + end + return nextFn(game, dt) + end, math.huge, "engine:portable_scripts") +end +function Scripts.cancelAll(loader, modId, reason) + if loader and modId then + if contexts[loader] then + contexts[loader].owners[modId] = nil + end + elseif loader then + contexts[loader] = nil + else + for owner in pairs(contexts) do + contexts[owner] = nil + end + end + local wasDraining = draining + draining = true + local pending = {} + for _, token in pairs(runs) do + if (not loader or token.loader == loader) and (not modId or token.modId == modId) then + pending[#pending + 1] = token + end + end + for _, token in ipairs(pending) do + token.finish(false, reason or "script session ended") + end + draining = wasDraining +end +local function facade(loader, modId, native) + local context = contexts[loader] + if not context then + context = { owners = {} } + contexts[loader] = context + end + local lease = {} + context.owners[modId] = lease + local function licensed() + return not draining and contexts[loader] == context and context.owners[modId] == lease + end + -- Install before entry code can subscribe/queue from input.step. Sorting + -- a new observer into a live hook chain could replay its current wrapper. + observe(loader) + local world, active = {}, nil + local function queue(rows, options) + if not licensed() then + return nil, "script facade is retired" + end + local program, why = Program.compile(rows) + if not program then + return nil, why + end + if options ~= nil then + if type(options) ~= "table" or getmetatable(options) ~= nil then + return nil, "options must be a plain table" + end + for key, value in pairs(options) do + if key ~= "onDone" or type(value) ~= "function" then + return nil, "only onDone callback is supported" + end + end + end + local onDone = options and options.onDone + local game = current(loader) + local ow + ow, why = ready(game) + if not ow then + return nil, why + end + local Runner = require("src.script.ScriptRunner") + local runner = Runner.new(game, ow) + local token = { + loader = loader, + modId = modId, + game = game, + world = ow, + runner = runner, + original = ow.runner, + stack = game.stack, + states = game.stack.states, + mapId = ow.map.id, + x = ow.player.cellX, + y = ow.player.cellY, + save = game.save, + data = game.data, + map = ow.map, + player = ow.player, + version = GameVersion.get(), + ui = {}, + steps = 0, + } + function token.valid() + if token.done then + return nil, "script is finished" + end + if + not licensed() + or current(loader) ~= game + or game.save ~= token.save + or game.data ~= token.data + or game.stack ~= token.stack + or game.stack.states ~= token.states + or not contains(token.stack, ow) + or game.overworld and game.overworld ~= ow + or ow.map ~= token.map + or ow.map.id ~= token.mapId + or ow.player ~= token.player + or ow.player.cellX ~= token.x + or ow.player.cellY ~= token.y + or ow.runner ~= runner + or GameVersion.get() ~= token.version + or Runtime.hooks ~= loader.hooks + or Runtime.events ~= loader.events + or game.linkSession + or game.linkNet and not game.linkNet.closed + or ow.transitioning + then + return nil, "script session changed" + end + return true + end + function token.finish(ok, reason) + if token.done then + return + end + token.done = true + runner.co, runner.waitingFrames, runner.waitingCheck = nil, nil, nil + -- Remove only this run's exact native screens, preserving foreign + -- overlays and a replacement world's runner/locks. + for i = #token.ui, 1, -1 do + remove(token.stack, token.ui[i]) + end + if ow.runner == runner then + ow.runner = token.original + end + if runs[game] == token then + runs[game] = nil + end + if active == token then + active = nil + end + if Runtime.wants("script.ended") then + Runtime.emit("script.ended", { ctx = runner.ctx, completed = ok }) + end + if onDone then + local called, err = pcall(onDone, ok, reason) + if not called then + Runtime.reportError(modId, "portable script completion failed: " .. message(err)) + end + end + end + local function check() + local valid, reason = token.valid() + if not valid then + token.finish(false, reason) + end + return valid + end + local function owned(state, step) + token.ui[#token.ui + 1] = state + local update = state.update + function state:update(...) + if not check() or token.step ~= step or game.stack:top() ~= self then + return + end + local ok, err = engine(update, self, ...) + if not ok then + token.finish(false, "native UI failed: " .. message(err)) + end + end + return state + end + local function dialog(ctx, text, asking, step) + local TextBox = require("src.render.TextBox") + local box, answered + local function answer(value) + if answered or token.step ~= step or not check() then + return + end + answered = true + token.waitingUI = nil + ctx.lastCheck = value + runner:resume() + end + box = TextBox.new(game, TextBox.fromProse(text), function() + if not asking and not contains(token.stack, box) then + answer(nil) + end + end) + if asking then + local update, pushed, selected = box.update, false, nil + box.choice = function(yes) + if token.step ~= step or not check() or not selected or contains(token.stack, selected) then + return + end + remove(token.stack, box) + answer(yes) + end + function box:update(dt) + if self.done and (self.holdFrames or 0) <= 0 and not pushed then + pushed, self.choicePushed = true, true + local Choice = require("src.ui.ChoiceBox") + local geometry = self.choiceBox or {} + selected = owned( + Choice.new( + game, + self.choice, + { anchor = "bottom", tx = geometry.tx, ty = geometry.ty, tw = geometry.tw, th = geometry.th } + ), + step + ) + token.waitingUI = selected + game.stack:push(selected) + else + update(self, dt) + end + end + end + owned(box, step) + token.waitingUI = box + game.stack:push(box) + runner:yield() + end + local function choice(ctx, labels, opts, step) + local Menu = require("src.ui.Menu") + local items, menu, answered = {}, nil, false + local function pick(index) + if answered or token.step ~= step or not check() or contains(token.stack, menu) then + return + end + answered = true + token.waitingUI = nil + ctx.lastCheck, ctx.lastChoice = index == 1, { index = index, label = labels[index] } + runner:resume() + end + for i, label in ipairs(labels) do + items[i] = { + label = label, + onSelect = function() + pick(i) + end, + } + end + menu = owned( + Menu.new(game, items, { + onCancel = function() + pick(opts.cancel) + end, + }), + step + ) + menu.index = opts.default + token.waitingUI = menu + game.stack:push(menu) + runner:yield() + end + function runner:exec(compiled, ctx) + local pc, slice = 1, 0 + while pc <= #compiled do + if not check() then + return + end + if slice >= Program.SLICE then + self.waitingFrames = 1 + self:yield() + slice = 0 + if not check() then + return + end + end + token.steps, slice = token.steps + 1, slice + 1 + if token.steps > Program.MAX_STEPS then + error("portable script instruction budget exceeded", 0) + end + local row = compiled[pc] + local verb = row[1] + token.step = {} + self.lastRow = verb .. " (portable row " .. pc .. ")" + ctx.script, ctx.pc = compiled, pc + if verb == "wait" then + self.waitingFrames = row[2] + self:yield() + elseif verb == "show_text" or verb == "ask" then + dialog(ctx, row[2], verb == "ask", token.step) + elseif verb == "choice" then + choice(ctx, row[2], row[3], token.step) + elseif verb == "end" then + return + elseif + verb == "jump" + or verb == "jump_if_true" and ctx.lastCheck + or verb == "jump_if_false" and not ctx.lastCheck + then + pc = row[2] - 1 + end + pc = pc + 1 + end + end + local update = Runner.update + function runner:resume(...) + if not check() or not self.co or coroutine.status(self.co) == "running" then + return + end + local ok, err = coroutine.resume(self.co, ...) + if not ok then + Runtime.reportError(modId, "portable script failed: " .. message(err)) + token.finish(false, "script failed: " .. message(err)) + elseif not token.done and coroutine.status(self.co) == "dead" then + token.finish(true) + end + end + function runner:update(...) + if not check() or game.stack:top() ~= ow then + return + end + local ok, err = engine(update, self, ...) + if not ok then + token.finish(false, "script update failed: " .. message(err)) + end + end + function runner:run() + error("portable script owns this runner", 0) + end + -- Session/argument checks above do not mutate gameplay. Admission starts + -- here; subsequent native failures complete through onDone(false, reason). + active, runs[game], ow.runner = token, token, runner + runner.ctx = runner:makeContext({ + source = { modId = modId, mapId = ow.map.id, hook = "queueScript", strict = true }, + }) + runner.co = coroutine.create(function() + runner:exec(program, runner.ctx) + end) + if Runtime.wants("script.started") then + Runtime.emit("script.started", { ctx = runner.ctx }) + end + local ok, err = engine(runner.resume, runner) + if not ok then + token.finish(false, "script start failed: " .. message(err)) + end + return true + end + function world:queueScript(rows, options) + local ok, result, reason = engine(queue, rows, options) + if not ok then + return nil, "portable script validation failed" + end + return result, reason + end + function world:cancelScript() + if not active or active.done then + return false, "no portable script" + end + active.finish(false, "script cancelled") + return true + end + return setmetatable(world, { + __index = function(_, key) + local value = native() + local member = value and value[key] + if type(member) == "function" then + return function(_, ...) + return member(value, ...) + end + end + return member + end, + }) +end +function Scripts.wrap(mt, api, loader, manifest) + local format = manifest.script_format or "native" + api.scriptFormat = format + if format ~= "unified" then + return mt + end + local index = mt.__index + local world = facade(loader, manifest.id, function() + return index(api, "world") + end) + mt.__index = function(self, key) + if key == "world" then + return world + end + return index(self, key) + end + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "scripts" then + if Program.supports(name) then + return true + end + return false, "unsupported portable script command" + end + if surface == "world" and (name == "queueScript" or name == "cancelScript") then + return true + end + return supports(self, surface, name) + end + end + return mt +end +return Scripts diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua new file mode 100644 index 000000000..4bfc0a813 --- /dev/null +++ b/src/mods/ScriptProgram.lua @@ -0,0 +1,160 @@ +-- Pure preflight for the bounded portable dialogue/control-flow language. +local Program = {} +Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 +local ARITY = { + show_text = 2, + ask = 2, + choice = 3, + wait = 2, + label = 2, + jump = 2, + jump_if_true = 2, + jump_if_false = 2, + ["end"] = 1, +} +function Program.supports(verb) + return type(verb) == "string" and (ARITY[verb] ~= nil or verb == "text") +end +local function integer(n, lo, hi) + return type(n) == "number" and n == n and n % 1 == 0 and n >= lo and n <= hi +end +local function array(t, max) + if type(t) ~= "table" or getmetatable(t) ~= nil then + return nil + end + local n = 0 + for k in pairs(t) do + if not integer(k, 1, max) then + return nil + end + n = n + 1 + end + for i = 1, n do + if rawget(t, i) == nil then + return nil + end + end + return n +end +local function label(s) + return type(s) == "string" and #s > 0 and #s <= 64 and s:match("^[%w_:%-]+$") ~= nil +end +local function text(value, max) + if type(value) ~= "string" or #value > max or value:find("%z") then + return false + end + local i = 1 + while i <= #value do + local b = value:byte(i) + local n = b < 128 and 1 + or b >= 194 and b <= 223 and 2 + or b >= 224 and b <= 239 and 3 + or b >= 240 and b <= 244 and 4 + if not n or i + n - 1 > #value then + return false + end + for j = i + 1, i + n - 1 do + local c = value:byte(j) + if c < 128 or c > 191 then + return false + end + end + if n > 1 then + local c = value:byte(i + 1) + if b == 224 and c < 160 or b == 237 and c > 159 or b == 240 and c < 144 or b == 244 and c > 143 then + return false + end + end + i = i + n + end + return true +end +function Program.compile(raw) + local n = array(raw, Program.MAX_ROWS) + if not n then + return nil, "script must be a dense plain array of at most 1024 rows" + end + local rows, labels, bytes = {}, {}, 0 + local function fail(i, message) + return nil, "row " .. i .. ": " .. message + end + for i = 1, n do + local source, count = raw[i], array(raw[i], 3) + if not count or count == 0 then + return fail(i, "expected a dense plain command row") + end + local verb = source[1] == "text" and "show_text" or source[1] + if type(verb) ~= "string" or not ARITY[verb] then + return fail(i, "unsupported portable command") + end + if count > ARITY[verb] or count < (verb == "choice" and 2 or ARITY[verb]) then + return fail(i, "invalid command arity") + end + local row = { verb, source[2] } + if verb == "show_text" or verb == "ask" then + if not text(source[2], 4096) then + return fail(i, "text must be valid UTF-8 without NUL, at most 4096 bytes") + end + bytes = bytes + #source[2] + elseif verb == "choice" then + local size = array(source[2], 16) + if not size or size == 0 then + return fail(i, "choice needs 1-16 labels") + end + row[2] = {} + for j = 1, size do + local value = source[2][j] + if not text(value, 128) or #value == 0 then + return fail(i, "invalid choice label") + end + row[2][j], bytes = value, bytes + #value + end + local opts = source[3] + row[3] = { default = 1, cancel = size } + if opts ~= nil then + if type(opts) ~= "table" or getmetatable(opts) ~= nil then + return fail(i, "choice options must be a plain table") + end + for key, value in pairs(opts) do + if (key ~= "default" and key ~= "cancel") or not integer(value, 1, size) then + return fail(i, "invalid choice option") + end + row[3][key] = value + end + end + elseif verb == "wait" then + if not integer(source[2], 0, 36000) then + return fail(i, "wait must be 0-36000 native logic frames") + end + elseif verb == "label" then + if not label(source[2]) or source[2] == "end" or labels[source[2]] then + return fail(i, "invalid or duplicate label") + end + labels[source[2]] = i + elseif verb ~= "end" then + if not label(source[2]) and not integer(source[2], 1, n) then + return fail(i, "invalid jump target") + end + end + if bytes > 131072 then + return fail(i, "script text exceeds 128 KiB") + end + rows[i] = row + end + for i, row in ipairs(rows) do + if row[1] == "jump" or row[1] == "jump_if_true" or row[1] == "jump_if_false" then + local target = row[2] + if type(target) == "string" then + if target == "end" then + row[2] = n + 1 + elseif labels[target] then + row[2] = labels[target] + else + return fail(i, "jump to missing label") + end + end + end + end + return rows +end +return Program diff --git a/tests/engine/portable_world_scripts.lua b/tests/engine/portable_world_scripts.lua new file mode 100644 index 000000000..ccef037d9 --- /dev/null +++ b/tests/engine/portable_world_scripts.lua @@ -0,0 +1,856 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local Manifest = require("src.mods.Manifest") +local Program = require("src.mods.ScriptProgram") +local Runtime = require("src.mods.Runtime") +local Runner = require("src.script.ScriptRunner") +local WorldAPI = require("src.world.WorldAPI") +local GameVersion = require("src.core.GameVersion") +local Font = require("src.render.Font") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local Json = require("src.link.Json") +local originalVersion = GameVersion.get() +local function boot(version) + GameVersion.set(version) + local data, files = T.fixtures.fresh(), {} + for _, id in ipairs({ "probe", "other", "legacy" }) do + files["mods/" .. id .. "/manifest.json"] = Json.encode({ + id = id, + name = id, + version = "1.0.0", + api = 2, + entry = "main.lua", + script_format = id ~= "legacy" and "unified" or nil, + content_format = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and id ~= "legacy" and "unified" + or nil, + callback_format = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and id ~= "legacy" and "unified" + or nil, + description = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and "all-formats" or "", + }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + if mod.id ~= "legacy" then + assert(mod.scriptFormat == "unified") + if mod.manifest.description == "all-formats" then + assert(mod.contentFormat == "unified") + assert(mod.callbackFormat == "unified") + end + local ok, why = mod.world:queueScript({ { "wait", 1 } }) + assert(ok == nil and type(why) == "string") + else assert(mod.scriptFormat == "native") end + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other", "legacy" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" or p == "mods/legacy" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + T.check(loader:load(data), "fixture mods load") + T.eq(package.loaded["src.core.Game"], nil, "early portable request does not bootstrap Game") + Font.load(data) + local game = { + data = data, + save = SaveData.newGame(), + stack = setmetatable({ states = {} }, { __index = StateStack }), + } + local ow = { + isOverworld = true, + map = { id = "FIX_TOWN", def = data.maps.FIX_TOWN }, + player = { cellX = 4, cellY = 5, facing = "up" }, + scriptMoves = {}, + pendingScripts = {}, + parallelRunners = {}, + } + game.overworld, ow.game = ow, game + ow.runner = Runner.new(game, ow) + function ow:update() + self.runner:update() + end + game.stack.states = { ow } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + loader.game = game + return loader.apis.probe.world, game, ow, loader, files +end +local function tick(game, key) + game.input.wasPressed = function(_, k) + return k == key + end + Runtime.call("input.step", function() end, game, 1 / 60) + game.stack:update(1 / 60) + game.input.wasPressed = function() + return false + end +end +local function untilState(game, predicate) + for _ = 1, 2000 do + if predicate() then + return + end + tick(game) + end + error("native UI did not reach requested state") +end +local function finishText(game) + local box = game.stack:top() + untilState(game, function() + return box.done or box.waiting and (box.preWait or 0) <= 0 + end) + tick(game, "a") +end +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local before, calls, result = ow.runner, 0, nil + T.check( + world:queueScript({ { "wait", 2 }, { "text", "First" }, { "end" }, { "text", "Unreachable" } }, { + onDone = function(ok, why) + calls = calls + 1 + result = { ok, why } + end, + }), + version .. " program admitted" + ) + T.neq(ow.runner, before, "isolated native runner installed") + T.eq(ow.runner:isRunning(), true, "runner locks foreground input") + tick(game) + T.eq(game.stack:top(), ow, "wait does not end early") + tick(game) + T.neq(game.stack:top(), ow, "native text appears after wait") + T.eq(calls, 0, "completion awaits text") + finishText(game) + T.same(result, { true }, "success completion") + T.eq(calls, 1, "completion once") + T.eq(ow.runner, before, "idle native runner restored") + T.eq(game.stack:top(), ow, "script text closed") + + local Choice = require("src.ui.ChoiceBox") + for _, yes in ipairs({ true, false }) do + local lines = {} + T.check( + world:queueScript({ + { "ask", "Question?" }, + { "jump_if_false", "no" }, + { "text", "YES" }, + { "jump", "end" }, + { "label", "no" }, + { "text", "NO" }, + }, { + onDone = function(ok) + lines[#lines + 1] = ok + end, + }), + "question script admitted" + ) + untilState(game, function() + return getmetatable(game.stack:top()) == Choice + end) + local choice = game.stack:top() + tick(game, yes and "a" or "b") + T.eq(choice.pending, yes, "native answer pending") + T.eq(#lines, 0, "answer hold preserved") + untilState(game, function() + return game.stack:top() ~= choice + end) + T.same( + game.stack:top().pages, + require("src.render.TextBox").paginate( + yes and "YES" or "NO", + game.stack:top().maxCols, + game.stack:top().letterSpacing, + game.stack:top().maxPixels, + true + ), + "correct branch text" + ) + finishText(game) + T.same(lines, { true }, "question branch completes") + end + local trace = {} + T.check( + world:queueScript({ + { "choice", { "ONE", "TWO", "THREE" }, { default = 2, cancel = 3 } }, + { "jump_if_false", 5 }, + { "text", "WRONG" }, + { "end" }, + { "text", "RIGHT" }, + }, { + onDone = function(ok) + trace[#trace + 1] = ok + end, + }), + "multiple choice admitted" + ) + T.eq(game.stack:top().index, 2, "native default selection") + tick(game, "b") + finishText(game) + T.same(trace, { true }, "native B choice follows false branch") + T.eq(loader.apis.legacy.world.queueScript, WorldAPI.queueScript, "legacy method unchanged") +end + +local function freeze(root) + local graph = {} + local function visit(t) + if type(t) ~= "table" or graph[t] then + return + end + local entry = { mt = getmetatable(t), values = {} } + graph[t] = entry + for k, v in pairs(t) do + entry.values[k] = v + visit(k) + visit(v) + end + end + visit(root) + return function(label) + local same = true + for t, entry in pairs(graph) do + same = same and getmetatable(t) == entry.mt + for k, v in pairs(entry.values) do + same = same and rawget(t, k) == v + end + for k, v in pairs(t) do + same = same and entry.values[k] == v + end + end + T.check(same, label) + end +end +local function refusal(world, game, rows, options, label) + local unchanged = freeze(game) + local ok, why = world:queueScript(rows, options) + T.eq(ok, nil, label) + T.eq(type(why), "string", label .. " has reason") + unchanged(label .. " is observational") +end +local badPrograms = { + false, + 1, + "text", + function() end, + { [1] = { "wait", 1 }, [3] = { "end" } }, + { [1] = { "wait", 1 }, extra = true }, + setmetatable({ { "end" } }, {}), + { false }, + { {} }, + { { "wait" } }, + { { "wait", 1, 2 } }, + { { "wait", -1 } }, + { { "wait", 1.5 } }, + { { "wait", 0 / 0 } }, + { { "wait", math.huge } }, + { { "wait", 36001 } }, + { { "end", false } }, + { { "native_mutate", {} } }, + { { "show_text", {} } }, + { { "show_text", string.rep("x", 4097) } }, + { { "text", "Valid" }, { "give_item", "POTION" } }, + { { "text", "Valid" }, { "jump", "missing" } }, + { { "label", "same" }, { "label", "same" } }, + { { "label", "end" } }, + { { "label", "bad\nname" } }, + { { "jump", 0 } }, + { { "jump", 99 } }, + { { "jump", false } }, + { { "choice", {} } }, + { { "choice", { "A", "B" }, { cancel = 3 } } }, + { { "choice", { "A" }, { default = 0 } } }, + { { "choice", { "A" }, { custom = true } } }, + { { "choice", { "A", function() end } } }, + { { "choice", { "" } } }, + { { "choice", { "A" }, setmetatable({}, {}) } }, + { setmetatable({ "wait", 1 }, {}) }, +} +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local callbacks = 0 + for _, rows in ipairs(badPrograms) do + refusal(world, game, rows, { + onDone = function() + callbacks = callbacks + 1 + end, + }, "invalid whole program") + end + for _, options in ipairs({ + false, + 1, + "options", + { onDone = true }, + { onDone = function() end, runner = {} }, + setmetatable({}, {}), + }) do + refusal(world, game, { { "wait", 1 } }, options, "invalid options") + end + T.eq(callbacks, 0, "rejected lists never complete or execute a prefix") + for _, key in ipairs({ + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", + }) do + ow[key] = true + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns world") + ow[key] = nil + end + for _, key in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + ow.player[key] = true + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns player") + ow.player[key] = nil + end + for _, key in ipairs({ "pendingScripts", "scriptMoves", "parallelRunners", "parallelQueue" }) do + ow[key] = { {} } + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns script scheduling") + ow[key] = {} + end + game.linkSession = {} + refusal(world, game, { { "wait", 1 } }, nil, "link session busy") + game.linkSession = nil + game.linkNet = { closed = false } + refusal(world, game, { { "wait", 1 } }, nil, "link transport busy") + game.linkNet = nil + game.stack:push({}) + refusal(world, game, { { "wait", 1 } }, nil, "covered world busy") + game.stack:pop() + ow.runner:run({ { "wait", 9 } }) + refusal(world, game, { { "wait", 1 } }, nil, "native script busy") + for _ = 1, 9 do + tick(game) + end + T.check(world:queueScript({ { "wait", 1 } }), "new script after native completion") + tick(game) + T.eq(loader.apis.other.world:cancelScript(), false, "another mod cannot cancel a program") +end +-- Limits, data copying and compiler immutability, including a cyclic payload. +do + local world, game, ow = boot("crystal") + local over = {} + for i = 1, 1025 do + over[i] = { "end" } + end + refusal(world, game, over, nil, "row bound") + over = {} + for i = 1, 33 do + over[i] = { "text", string.rep("x", 4096) } + end + refusal(world, game, over, nil, "total text bound") + local cycle = {} + cycle[1] = cycle + refusal(world, game, cycle, nil, "cyclic command denied") + local rows = { { "wait", 2 }, { "choice", { "ONE", "TWO" }, { default = 2 } } } + local unchanged = freeze(rows) + local compiled = Program.compile(rows) + unchanged("compiler leaves caller rows intact") + T.neq(compiled[2][2], rows[2][2], "compiler copies nested labels") + T.check(world:queueScript(rows), "copied program accepted") + rows[2][1], rows[2][2][2], rows[2][3].default = "invalid", "MUTATED", 1 + tick(game) + tick(game) + T.eq(game.stack:top().index, 2, "choice options captured before caller edit") + T.eq(game.stack:top().items[2].label, "TWO", "choice labels copied before execution") + world:cancelScript() +end +-- Session changes cancel even while an old Game is no longer being stepped. +for _, version in ipairs({ "red", "crystal", "emerald", "platinum", "heartgold", "black" }) do + for _, change in ipairs({ + "save", + "data", + "stack", + "states", + "map", + "mapId", + "player", + "position", + "runner", + "game", + "version", + "removed", + "transition", + }) do + local world, game, ow, loader = boot(version) + local originalRunner, calls, result = ow.runner, 0 + T.check( + world:queueScript({ { "wait", 30 }, { "text", "Must not run" } }, { + onDone = function(ok, why) + calls = calls + 1 + result = { ok, why } + end, + }), + "session test admitted" + ) + local stale = ow.runner + local replacement + if change == "save" then + game.save = SaveData.newGame() + elseif change == "data" then + game.data = T.fixtures.fresh() + elseif change == "stack" then + game.stack = setmetatable({ states = { ow } }, { __index = StateStack }) + elseif change == "states" then + game.stack.states = { ow } + elseif change == "map" then + ow.map = { id = "FIX_ROUTE", def = game.data.maps.FIX_ROUTE } + elseif change == "mapId" then + ow.map.id = "FIX_ROUTE" + elseif change == "player" then + ow.player = { cellX = 4, cellY = 5 } + elseif change == "position" then + ow.player.cellX = 7 + elseif change == "runner" then + replacement = Runner.new(game, ow) + ow.runner = replacement + elseif change == "game" then + replacement = { stack = setmetatable({ states = {} }, { __index = StateStack }), input = game.input } + loader.game = replacement + elseif change == "version" then + GameVersion.set(version == "red" and "crystal" or "red") + elseif change == "removed" then + game.stack.states = {} + elseif change == "transition" then + ow.transitioning = true + end + Runtime.call("input.step", function() end, change == "game" and replacement or game, 1 / 60) + T.eq(calls, 1, change .. " cancellation delivered") + T.eq(result[1], false, "session cancellation is not success") + T.eq(type(result[2]), "string", "cancellation gives reason") + T.eq(ow.runner, change == "runner" and replacement or originalRunner, "restore only owned runner") + stale:resume() + stale:update() + T.eq(calls, 1, "old runner callbacks cannot replay cancellation") + T.eq(stale:isRunning(), false, "abandoned coroutine released") + GameVersion.set(version) + end +end +-- Cancel removes only owned windows, including the choice above its text. +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local originalRunner, calls = ow.runner, 0 + T.check( + world:queueScript({ { "ask", "Question?" }, { "text", "Later" } }, { + onDone = function(ok) + T.eq(ok, false, "explicit cancel completion") + calls = calls + 1 + end, + }), + "cancellable question" + ) + local box = game.stack:top() + untilState(game, function() + return game.stack:top() ~= box + end) + local choice = game.stack:top() + local foreign = { update = function() end } + game.stack:push(foreign) + T.eq(loader.apis.other.world:cancelScript(), false, "foreign mod has no cancellation authority") + T.check(world:cancelScript(), "owner cancels") + T.same(game.stack.states, { ow, foreign }, "foreign overlay survives cancellation") + T.eq(ow.runner, originalRunner, "cancel restores idle runner") + T.eq(calls, 1, "cancel callback once") + choice.onChoose(true) + box.onDone() + choice:update(1 / 60) + box:update(1 / 60) + T.same(game.stack.states, { ow, foreign }, "stale native UI cannot pop foreign screen") + T.eq(calls, 1, "stale native callbacks do not complete twice") +end +-- Replaying an old callback during a later row must not skip that row's wait. +do + local world, game, ow = boot("red") + T.check( + world:queueScript({ { "text", "First" }, { "wait", 10 }, { "text", "Last" } }), + "callback replay test admitted" + ) + local old = game.stack:top() + finishText(game) + T.eq(ow.runner.waitingFrames, 10, "later wait begins") + old.onDone() + old:update(1 / 60) + T.eq(ow.runner.waitingFrames, 10, "old text callback cannot skip current wait") + for _ = 1, 10 do + tick(game) + end + T.neq(game.stack:top(), ow, "new text after full wait") + world:cancelScript() +end + +-- Native error cleanup, completion exceptions and event reentry are bounded. +for _, version in ipairs({ "red", "crystal", "emerald", "platinum", "black" }) do + local world, game, ow, loader = boot(version) + local TextBox = require("src.render.TextBox") + local original, make = ow.runner, TextBox.new + local completions, result = 0 + TextBox.new = function() + error("injected native constructor failure") + end + Runtime.currentMod = "probe" + T.check( + world:queueScript({ { "text", "Fail" } }, { + onDone = function(ok, why) + completions = completions + 1 + result = { ok, why } + end, + }), + "entered native failure is admitted" + ) + T.eq(Runtime.currentMod, "probe", "engine ownership restored on failure") + Runtime.currentMod = nil + TextBox.new = make + T.eq(result[1], false, "native error completes as failure") + T.check(result[2]:find("constructor failure", 1, true) ~= nil, "native error reason retained") + T.eq(completions, 1, "native error completion once") + T.eq(ow.runner, original, "native error releases runner") + T.check( + world:queueScript({}, { + onDone = function() + error("completion failure") + end, + }), + "callback error isolated" + ) + T.eq(ow.runner, original, "callback error cannot strand runner") + local events = {} + local stop1 = loader.events:on("script.started", function(ev) + if ev.ctx.source and ev.ctx.source.modId == "probe" then + events[#events + 1] = "started" + refusal( + loader.apis.other.world, + game, + { { "wait", 1 } }, + nil, + "event callback cannot reenter owned run" + ) + end + end) + local stop2 = loader.events:on("script.ended", function(ev) + if ev.ctx.source and ev.ctx.source.modId == "probe" then + events[#events + 1] = ev.completed and "success" or "failure" + end + end) + T.check(world:queueScript({ { "wait", 1 } }), "eventful wait admitted") + tick(game) + T.same(events, { "started", "success" }, "one lifecycle event pair") + T.check(world:queueScript({ { "wait", 10 } }), "eventful cancel admitted") + world:cancelScript() + T.same(events, { "started", "success", "started", "failure" }, "cancel emits failure once") + stop1() + stop2() +end +-- An infinite authored jump yields slices to the engine and eventually +-- reports failure rather than blocking a frame or permanently owning input. +do + local world, game, ow = boot("red") + local original, result, ticks = ow.runner, nil, 0 + T.check( + world:queueScript({ { "label", "loop" }, { "jump", "loop" } }, { + onDone = function(ok, why) + result = { ok, why } + end, + }), + "loop admitted" + ) + T.eq(result, nil, "loop yields back from initial call") + untilState(game, function() + ticks = ticks + 1 + return result ~= nil + end) + T.check(ticks > 1, "loop spans engine updates") + T.eq(result[1], false, "instruction limit fails explicitly") + T.check(result[2]:find("budget", 1, true) ~= nil, "budget reason") + T.eq(ow.runner, original, "budget exhaustion releases world") +end +-- Teardown/reload and failed entry chunks retire captured facades. Neither +-- completion nor a script-ended listener may start fresh work during teardown. +for _, mode in ipairs({ "teardown", "reload", "rollback" }) do + local world, game, ow, loader, files = boot("emerald") + local original, calls, retry = ow.runner, 0 + T.check( + world:queueScript({ { "text", "Pending" } }, { + onDone = function(ok) + T.eq(ok, false, "lifecycle cancellation") + calls = calls + 1 + retry = world:queueScript({ { "wait", 1 } }) + end, + }), + "lifecycle program accepted" + ) + if mode == "teardown" then + Loader.teardown() + elseif mode == "reload" then + boot("emerald") + else + loader:_rollback("probe") + end + T.eq(calls, 1, "lifecycle completion once") + T.eq(retry, nil, "no restart while retiring") + T.eq(ow.runner, original, "lifecycle restores runner") + T.same(game.stack.states, { ow }, "lifecycle removes owned text") + refusal(world, game, { { "wait", 1 } }, nil, "retained API retired") +end +do + local _, game, ow, loader, files = boot("crystal") + local original = ow.runner + files["mods/probe/main.lua"] = [[return function(mod) + assert(mod.world:queueScript({ { "wait", 30 } })) + error("entry failure after queue") + end]] + files["mods/other/main.lua"] = "return function() end" + local failed = Loader.new({ fs = loader.fs, dev = false }) + failed.game = game + failed:load(T.fixtures.fresh()) + T.eq(ow.runner, original, "failed entry cancels its accepted run") + T.eq(original:isRunning(), false, "failed entry cannot strand foreground") +end +-- Omitted/native formats preserve the old API 1/2 contract. Explicit malformed +-- or API-1 unified declarations must fail instead of silently changing mode. +do + local function manifest(format, level) + return { + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + api = level or 2, + script_format = format, + } + end + for _, bad in ipairs({ false, true, 1, {}, "portable", "" }) do + T.raises(function() + Manifest.validate(manifest(bad), "mods/probe") + end, "script_format", "invalid format") + end + T.raises(function() + Manifest.validate(manifest("unified", 1), "mods/probe") + end, "API 2", "unified requires API 2") + T.eq( + Manifest.validate(manifest(nil, 1), "mods/probe").script_format, + "native", + "API 1 default remains native" + ) + T.eq( + Manifest.validate(manifest("native", 1), "mods/probe").script_format, + "native", + "explicit API 1 native accepted" + ) +end +-- Per-frame traces against the real native runner/commands, rather than +-- against another implementation of the portable adapter. +local function stateRecord(game, ow) + local top = game.stack:top() + local record = + { depth = #game.stack.states, running = ow.runner:isRunning(), wait = ow.runner.waitingFrames } + if top ~= ow then + record.index, record.pending, record.hold = top.index, top.pending, top.holdFrames + record.page, record.line, record.char = top.pageIndex, top.lineIndex, top.charIndex + record.done, record.waiting, record.preWait = top.done, top.waiting, top.preWait + if top.shown then + record.shown = Json.decode(Json.encode(top.shown)) + end + if top.items then + record.labels = {} + for _, item in ipairs(top.items) do + record.labels[#record.labels + 1] = item.label + end + end + end + return record +end +for _, version in ipairs(GameVersion.ORDER) do + for _, yes in ipairs({ true, false }) do + local traces = {} + for _, native in ipairs({ true, false }) do + local world, game, ow = boot(version) + local rows = { + { "wait", 2 }, + { "show_text", "First\fSecond" }, + { "ask", "Question?" }, + { "jump_if_false", "no" }, + { "choice", { "ONE", "TWO" }, { default = 2, cancel = 2 } }, + { "jump", "end" }, + { "label", "no" }, + { "show_text", "NO" }, + } + local completed, trace = false, {} + local saveUnchanged = freeze(game.save) + if native then + ow.runner:run(rows, { + onDone = function() + completed = true + end, + }) + else + T.check( + world:queueScript(rows, { + onDone = function(ok) + T.eq(ok, true, "parity program succeeds") + completed = true + end, + }), + "parity program admitted" + ) + end + for frame = 1, 2000 do + trace[#trace + 1] = stateRecord(game, ow) + if completed then + break + end + local top = game.stack:top() + local key + if top.pending == nil and getmetatable(top) == require("src.ui.ChoiceBox") then + key = yes and "a" or "b" + elseif top.items then + key = "b" + elseif top.done or top.waiting and (top.preWait or 0) <= 0 then + key = "a" + end + tick(game, key) + end + T.eq(completed, true, "native parity trace completed") + saveUnchanged("dialogue program leaves save unchanged") + traces[#traces + 1] = trace + end + T.same(traces[1], traces[2], version .. " frame-by-frame native dialogue/wait/branch parity") + end +end + +do + local world, game, ow, loader = boot("black") + for _, invalid in ipairs({ + "bad\0text", + "\128", + "\192\128", + "\224\128\128", + "\237\160\128", + "\244\144\128\128", + "\240\159", + }) do + refusal( + world, + game, + { { "text", "Valid first" }, { "ask", invalid } }, + nil, + "invalid UTF-8 refused before prefix" + ) + end + T.check(world:queueScript({ { "text", "Grüße Pokémon ♀" } }), "valid UTF-8 accepted") + world:cancelScript() + local calls = 0 + local stop = loader.hooks:wrap("input.step", function(nextFn, ...) + calls = calls + 1 + return nextFn(...) + end, -1, "other") + for _ = 1, 3 do + T.check(world:queueScript({ { "wait", 2 } }), "reused observer admission") + world:cancelScript() + end + local before = calls + tick(game) + T.eq(calls, before + 1, "observer does not skip or duplicate downstream hooks") + stop() + local random = math.random + local rolls = 0 + math.random = function() + rolls = rolls + 1 + error("portable scripts must not roll RNG") + end + T.check(world:queueScript({ { "text", "FIRST" } }), "no-RNG dialogue") + finishText(game) + math.random = random + T.eq(rolls, 0, "no RNG consumed") + local events = {} + local done = loader.events:on("script.ended", function(ev) + events[#events + 1] = ev.completed + end) + T.check(world:queueScript({ { "text", "Native failure" } }), "native update failure admission") + local box = game.stack:top() + -- The guarded method captures the native update when the box is created. + -- Fail native work reached on a later frame instead of changing the guard. + box.beginLine = function() + error("native text update failure") + end + box.done, box.waiting, box.preWait, box.contAdvance = false, true, 0, true + tick(game, "a") + T.same(events, { false }, "native UI update error completes once") + T.eq(game.stack:top(), ow, "native update error releases owned UI") + done() +end +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local api = loader.apis.probe + if api.supports then + T.eq(api:supports("world", "queueScript"), true, "portable queue capability") + T.eq(api:supports("world", "cancelScript"), true, "portable cancel capability") + for _, verb in ipairs({ + "text", + "show_text", + "ask", + "choice", + "wait", + "label", + "jump", + "jump_if_true", + "jump_if_false", + "end", + }) do + T.eq(api:supports("scripts", verb), true, "bounded verb capability") + end + T.eq(api:supports("scripts", "warp"), false, "unsupported verb declined") + T.eq(loader.apis.legacy:supports("world", "cancelScript"), false, "legacy has no portable cancellation") + T.eq(loader.apis.legacy:supports("scripts", "wait"), false, "native format does not claim portable verbs") + end + T.eq(world:current().mapId, "FIX_TOWN", "unrelated world methods still delegate") + T.eq(world.game, game, "existing native fields still delegate") +end + +-- First admission from an input.step callback must not sort a new observer +-- into the chain while it is being traversed (which would replay the caller). +do + local world, game = boot("crystal") + local wrappers, vanilla = 0, 0 + local stop = Runtime.hooks:wrap("input.step", function(nextFn, ...) + wrappers = wrappers + 1 + if wrappers == 1 then + T.check(world:queueScript({ { "wait", 2 } }), "first queue inside input hook") + end + return nextFn(...) + end, 0, "probe") + Runtime.call("input.step", function() + vanilla = vanilla + 1 + end, game, 1 / 60) + T.eq(wrappers, 1, "first queue does not replay input callback") + T.eq(vanilla, 1, "native input boundary called once") + world:cancelScript() + stop() +end +GameVersion.set(originalVersion) +T.finish("portable world scripts") From b46e1289f0038b1538190b956ce07117e5fda066 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:07:37 +0200 Subject: [PATCH 11/71] Add read-only map overview and encounter table queries --- docs/modding.md | 2 + docs/world-overview.md | 105 +++++ src/mods/Loader.lua | 3 + src/mods/UnifiedIds.lua | 85 ++++ src/mods/WorldOverview.lua | 153 +++++++ src/world/MapOverview.lua | 176 ++++++++ src/world/WorldAPI.lua | 15 + tests/engine/world_overview_api.lua | 660 ++++++++++++++++++++++++++++ 8 files changed, 1199 insertions(+) create mode 100644 docs/world-overview.md create mode 100644 src/mods/UnifiedIds.lua create mode 100644 src/mods/WorldOverview.lua create mode 100644 src/world/MapOverview.lua create mode 100644 tests/engine/world_overview_api.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..2e08c6136 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -18,6 +18,8 @@ Regenerate the reference straight into a wiki checkout: luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +Additional read-only world summaries: [map overview and encounter queries](world-overview.md). + ## Bulk storage (`mod.storage`) `mod.save` is the right home for a handful of per-playthrough values: it lives diff --git a/docs/world-overview.md b/docs/world-overview.md new file mode 100644 index 000000000..e3c494197 --- /dev/null +++ b/docs/world-overview.md @@ -0,0 +1,105 @@ +# World overview and encounter queries + +These additive API-1/API-2 readers use the Gen1Recomp `mapOverview` and +`effectiveEncounters` result shapes. They return detached tables and do not +load maps, consume RNG, run mod callbacks, refresh objects, or change the save. +They are available through the normal lazy `mod.world` facade. Hosts with +`mod:supports` report both names on the `world` surface before Game exists. +This does not opt a mod into API 3 or change existing actions/encounter rolls. + +```lua +local overview, reason = mod.world:mapOverview() +if overview then + -- Cells and marker coordinates start at zero; Lua row arrays start at one. + local terrain = overview.rows[y + 1]:sub(x + 1, x + 1) +end + +local encounters, reason = mod.world:effectiveEncounters(mapId, "grass", { + daytime = "NITE", -- optional; MORN/MORNING, DAY, NITE/NIGHT +}) +if encounters then + local total = 0 + for species, weight in pairs(encounters.dist) do total = total + weight end + -- Conditional species share = weight / total (when total > 0). +end +``` + +## `mapOverview()` + +Requires an active overworld; menus/battles above it do not prevent reads. +At the title screen, returns `nil, "no overworld"`. + +| Field | Meaning | +| --- | --- | +| `mapId`, `width`, `height` | Active map ID and size in 16px walk cells | +| `rows` | `height` strings, each exactly `width` ASCII characters | +| `markers` | Array of detached `{kind, x, y}` records | +| `tileRows`, `tileWidth`, `tileHeight` | Optional visual shade grid at twice the cell resolution | +| `tileDetailRows`, `tileDetailWidth`, `tileDetailHeight` | Optional visual shade grid at four times the cell resolution | + +Terrain characters are `+` for a native warp tile, `~` for water, `.` for +walkable ground, and a space for a wall, in that order of precedence. This +summarizes terrain, not directional movement permissions, elevation crossings, +NPC occupancy, or a pathfinding result. Runtime block/collision patches win. +Packed GBA blocks are decoded privately; a cold map remains cold afterwards. + +Markers identify in-bounds `warp` events, `item` objects actually in the active +NPC list (at their current cells, excluding hidden objects and fruit trees), +and untaken `hidden` item spots. Hidden-item signs also honor their event flags +and their distinct native taken keys. Neighbour-map objects are not included. +The snapshot reflects the loaded object list; it does not run visibility or +spawn filters again. Hidden markers deliberately reveal item locations to the +requesting mod, as in Gen1Recomp. + +GB visual grids use the resolved, mod-overridable atlas's pixel luminance: +`"0"` is lightest, `"3"` darkest. `tileRows` averages each 8x8 tile; +`tileDetailRows` preserves each 4x4 quadrant. Missing/invalid art is omitted. +The private visual cache is invalidated with the engine's asset cache. +GBA metatile compositions and DS 3D art have no GB atlas, so visual grids +are omitted there. Terrain grids support GB/GBA maps and resident DS interior +permission grids. DS maps without a resident behaviour grid return +`nil, "map overview unavailable for streamed terrain"`; reading streamed +renderer chunks would load/cache terrain during a read query. + +## `effectiveEncounters(mapId, terrain, opts?)` + +Takes an explicit, known map ID, including at the title screen. `terrain` is +`"grass"`, `"water"`, or `"indoor"` (the grass table used by cave floors). +Returns `{chance = number, dist = {[publicSpeciesId] = integerWeight}}`. +Unknown maps, invalid terrain/options/time, unloaded encounter data and invalid +bucket/rate metadata return `nil, reason`. A known map without that table +returns `{chance = 0, dist = {}}`. + +`chance` is the table's independent integer rate gate, clamped to its native +denominator (256 by default, 100 on DS adapter tables). It is **not** the final +probability of a battle on the player's next step. `dist` aggregates consecutive +cumulative bucket differences for duplicate species. Weights are not percentages: +use their sum when computing conditional shares. Missing slots/uncovered bucket +space remain empty outcomes, just as in the native chooser. Zero-rate tables +return an empty distribution. Species IDs use the same dataset-derived public +names as the other portable readers, including numeric DS species and aliases. +Loader identity metadata is captured before presentation-name patches. + +Gen2 land tables use the current clock period, or the last effective cached +period when a `world.tod` hook is installed. `opts.daytime` overrides this +without dispatching the hook or updating the world. Active Gold/Silver/Crystal +swarm tables are selected by the native resolver. DS previews use the native +map-to-table indirection, day/night substitutions, active daily slots/outbreaks +and BW season selection. DS `daytime` selects a representative hour (6/12/22). +Water uses its own table. + +This is a **table preview**: it excludes per-step ability/radio/held-item rate +changes, movement/grace gates, Repel, roamers, lead-based slot filtering, +fishing, Radar, phenomena and dark-grass overrides. It never samples or executes +`encounter.roll`, `encounter.species`, or `world.tod`. Gen2Recomped has no native +`encounter.table` hook; this PR does not introduce one or pretend to preview +arbitrary mod callbacks. A future shared table-transform seam can extend both +the native roll and preview together. + +HGSS's position-dependent Safari Zone returns +`nil, "position-dependent Safari encounters unavailable"`; its native resolver +consults the global player's position and can initialize save state. Emerald's +Battle Pike wild room and Battle Pyramid floors return +`nil, "facility encounters unavailable"`; these use separate facility tables. +These refusals keep an explicit-map query from reporting another map's table +or changing gameplay state. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..ba7509711 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -23,6 +23,7 @@ local ModStorage = require("src.mods.Storage") local Runtime = require("src.mods.Runtime") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") +local WorldOverview = require("src.mods.WorldOverview") -- Module-name aliases for mods written against the Gold port's per-generation -- layout (src.world.gen2.Player and friends). Installed here because this is -- the module that loads mods: the searcher has to be in place before the @@ -1125,6 +1126,7 @@ function Loader:_api(mod) -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. local world, storage + local overviewContext = WorldOverview.install(api, loader) setmetatable(api, { __index = function(_, key) if key == "game" then return loader:_game() end if key == "storage" then @@ -1140,6 +1142,7 @@ function Loader:_api(mod) local module = game and engineRequire("src.world.WorldAPI") if not module then return nil end world = module.new(game, modId) + world.overviewContext = overviewContext return world end }) return api diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/src/mods/WorldOverview.lua b/src/mods/WorldOverview.lua new file mode 100644 index 000000000..491963d48 --- /dev/null +++ b/src/mods/WorldOverview.lua @@ -0,0 +1,153 @@ +-- Read-only world summaries. Do not call timeOfDay(), rollTable(), map loading, +-- or object visibility refreshes here: each can change the live simulation. +local Ids = require("src.mods.UnifiedIds") +local GameVersion = require("src.core.GameVersion") +local Overview = {} + +function Overview.context(loader) + Ids.new(loader):entries("pokemon") + return function() + return Ids.new(loader) + end +end + +function Overview.install(api, loader) + local context = Overview.context(loader) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and (name == "mapOverview" or name == "effectiveEncounters") then + return true + end + return supports(self, surface, name) + end + end + return context +end + +local function dataIds(data) + local rows = data.pokemon or {} + return Ids.new({ + content = { + pokemon = { + base = function() + return rows + end, + each = function() + return pairs(rows) + end, + }, + }, + }) +end + +local periods = { MORN = "MORNING", MORNING = "MORNING", DAY = "DAY", NITE = "NITE", NIGHT = "NITE" } +local hours = { MORNING = 6, DAY = 12, NITE = 22 } + +function Overview.encounters(game, ow, context, mapId, terrain, opts) + if terrain ~= "grass" and terrain ~= "water" and terrain ~= "indoor" then + return nil, "invalid terrain: " .. tostring(terrain) + end + if opts ~= nil and type(opts) ~= "table" then + return nil, "opts must be a table" + end + local daytime = opts and opts.daytime + if daytime ~= nil then + daytime = type(daytime) == "string" and periods[daytime:upper()] + if not daytime then + return nil, "invalid daytime" + end + end + local data = game and game.data + if not (data and type(data.encounters) == "table") then + return nil, "encounters data not loaded" + end + local def = data.maps and data.maps[mapId] + if not def then + return nil, "unknown map: " .. tostring(mapId) + end + -- HGSS's Safari table depends on the player's position in a six-area matrix. + -- Encounter.forMap resolves it through the Game singleton and seeds Safari + -- save state. Refuse this exceptional table instead of querying another game. + if def.cameraFamily == "hgss" and require("src.world.HgssSafariZone").isSafariMap(def) then + return nil, "position-dependent Safari encounters unavailable" + end + if GameVersion.get() == "emerald" then + if + require("src.script.Gen3BattlePike").inWildRoom(mapId) + or require("src.script.Gen3BattlePyramid").onFloor(mapId) + then + return nil, "facility encounters unavailable" + end + end + local Encounter = require("src.world.Encounter") + local hour = daytime and hours[daytime] + local enc = Encounter.forMap(data, def, mapId, hour, game.save) + if not daytime then + -- A world.tod hook's most recent effective period is already on the world. + -- Calling it again would execute mod code and can emit/change world state. + local Runtime = require("src.mods.Runtime") + if Runtime.wantsHook("world.tod") and ow then + daytime = ow.tod + end + if GameVersion.isGen2() then + if not daytime then + hour = tonumber(os.date("%H")) or 12 + daytime = hour < 4 and "NITE" or hour < 10 and "MORNING" or hour < 18 and "DAY" or "NITE" + end + else + daytime = daytime or (ow and ow.tod) or "DAY" + end + end + local slots = enc and enc[terrain == "indoor" and "grass" or terrain] + if terrain ~= "water" then + slots = Encounter.atTime(slots, daytime) + end + if not slots then + return { chance = 0, dist = {} } + end + local maximum = tonumber(slots.rateMax) or 256 + local rate = tonumber(slots.rate) or 0 + local span = tonumber(slots.bucketSpan) or 256 + if + maximum <= 0 + or maximum ~= math.floor(maximum) + or maximum == math.huge + or rate ~= rate + or rate == math.huge + or rate == -math.huge + or span <= 0 + or span ~= math.floor(span) + or span == math.huge + then + return nil, "invalid encounter rate/span" + end + local dist, prev = {}, 0 + local weights = slots.buckets or require("src.world.FieldDefaults").constant(data, "encounterBuckets") + if type(weights) ~= "table" or (slots.slots ~= nil and type(slots.slots) ~= "table") then + return nil, "invalid encounter buckets/slots" + end + local ids = context and context() or dataIds(data) + for i, threshold in ipairs(weights or {}) do + if + type(threshold) ~= "number" + or threshold ~= math.floor(threshold) + or threshold < prev + or threshold > span + then + return nil, "invalid encounter buckets" + end + local slot = slots.slots and slots.slots[i] + if rate > 0 and slot and slot.species and threshold > prev then + local id = ids:read("pokemon", slot.species) + if id then + dist[id] = (dist[id] or 0) + threshold - prev + end + end + prev = threshold + end + -- Same independent integer rate gate as rollTable, without drawing a slot. + return { chance = math.max(0, math.min(maximum, math.ceil(rate))) / maximum, dist = dist } +end + +return Overview diff --git a/src/world/MapOverview.lua b/src/world/MapOverview.lua new file mode 100644 index 000000000..adf912a6b --- /dev/null +++ b/src/world/MapOverview.lua @@ -0,0 +1,176 @@ +-- Generation-neutral terrain grid; optional GB tile luminance. All cells are +-- zero based. The scratch Map shares read-only data, but never the cold block +-- decoder's mutable definition or streamed renderer. +local Map = require("src.world.Map") +local Assets = require("src.render.Assets") +local Overview = {} +local shades = {} +Assets.register(function() + shades = {} +end) + +local function digit(sum, count) + return tostring(math.max(0, math.min(3, math.floor((1 - sum / count) * 3 + 0.5)))) +end + +local function visual(map) + local ts = map.tileset + -- GBA metatiles are composed/paletted by their renderer; their opaque tileAt + -- identifiers do not index a GB atlas. DS maps do not have one either. + if + map.blockCells ~= 2 + or not ts.blocks + or type(ts.image) ~= "string" + or type(ts.tilesPerRow) ~= "number" + or ts.tilesPerRow < 1 + or ts.tilesPerRow >= math.huge + or ts.tilesPerRow ~= math.floor(ts.tilesPerRow) + then + return nil + end + local path = Assets.resolve(ts.image) + local cache = shades[path] + if not cache then + if not (love and love.image and love.image.newImageData) then + return nil + end + local ok, pixels = pcall(love.image.newImageData, path) + if not ok or not pixels then + return nil + end + cache = { pixels = pixels, tiles = {} } + shades[path] = cache + end + local rows, details, pixels = {}, {}, cache.pixels + local width, height = pixels:getDimensions() + for y = 0, map.heightCells * 2 - 1 do + local row, top, bottom = {}, {}, {} + for x = 0, map.widthCells * 2 - 1 do + local tile = map:tileAt(x, y) + if type(tile) ~= "number" or tile < 0 or tile ~= math.floor(tile) then + return nil + end + local ox, oy = tile % ts.tilesPerRow * 8, math.floor(tile / ts.tilesPerRow) * 8 + if ox + 8 > width or oy + 8 > height then + return nil + end + local key = ts.tilesPerRow .. ":" .. tile + local s = cache.tiles[key] + if not s then + local sums = { 0, 0, 0, 0 } + for py = 0, 7 do + for px = 0, 7 do + local r, g, b = pixels:getPixel(ox + px, oy + py) + local q = math.floor(py / 4) * 2 + math.floor(px / 4) + 1 + sums[q] = sums[q] + r * 0.2126 + g * 0.7152 + b * 0.0722 + end + end + s = { + digit(sums[1] + sums[2] + sums[3] + sums[4], 64), + digit(sums[1], 16), + digit(sums[2], 16), + digit(sums[3], 16), + digit(sums[4], 16), + } + cache.tiles[key] = s + end + row[#row + 1], top[#top + 1], bottom[#bottom + 1] = s[1], s[2] .. s[3], s[4] .. s[5] + end + rows[#rows + 1] = table.concat(row) + details[#details + 1], details[#details + 2] = table.concat(top), table.concat(bottom) + end + return rows, details +end + +local function integer(n) + return type(n) == "number" and n >= 0 and n < math.huge and n == math.floor(n) +end + +function Overview.build(game, ow) + local map = ow.map + local w, h = map.widthCells, map.heightCells + if not integer(w) or not integer(h) or w == 0 or h == 0 then + return nil, "invalid map dimensions" + end + -- Outdoor DS terrain lives in lazily streamed renderer chunks. Walking that + -- renderer from a query can load/cache chunks. Interior permission grids are + -- already resident and can use the same pure Map classifiers below. + if map.def.generation == 4 and not map.def.behaviorCells then + return nil, "map overview unavailable for streamed terrain" + end + local scratch, def = {}, {} + for k, v in pairs(map) do + scratch[k] = v + end + for k, v in pairs(map.def) do + def[k] = v + end + scratch.def, scratch.renderer = def, nil + setmetatable(scratch, Map) + -- blockArray() writes only into the private definition on a cold GBA map. + -- It may reuse the warm immutable array, and honors the live block patches. + local rows, markers = {}, {} + local function marker(kind, x, y) + if integer(x) and integer(y) and x < w and y < h then + markers[#markers + 1] = { kind = kind, x = x, y = y } + end + end + for _, warp in ipairs(def.warps or {}) do + marker("warp", warp.x, warp.y) + end + -- The active object list is the result of native flag, session, time and + -- scripted spawn filters. Re-running those filters can consult another Game + -- singleton or initialize sprite state. Use the objects actually present. + for _, npc in ipairs(ow.npcs or {}) do + local obj = npc.def or {} + if obj.item and obj.item ~= 0 and obj.item ~= "0" and not npc.hidden and not obj.fruitTree then + marker("item", npc.cellX, npc.cellY) + end + end + local save = game.save or {} + local hidden = game.data and game.data.field and game.data.field.hiddenItems + for _, item in ipairs(hidden and hidden[map.id] or {}) do + if not (save.hiddenTaken and save.hiddenTaken[map.id .. "_" .. item.x .. "_" .. item.y]) then + marker("hidden", item.x, item.y) + end + end + -- Gen2/3 imports also carry hidden-item sign events, with their own taken + -- key and event flag (e.g. Cerulean Gym's Machine Part). + for _, item in ipairs(def.signs or {}) do + if + item.item + and item.item ~= 0 + and item.item ~= "0" + and not (item.eventFlag and save.flags and save.flags[item.eventFlag] == true) + and not (save.hiddenTaken and save.hiddenTaken[map.id .. "_sign_" .. item.x .. "_" .. item.y]) + then + marker("hidden", item.x, item.y) + end + end + for y = 0, h - 1 do + local row = {} + for x = 0, w - 1 do + row[#row + 1] = scratch:isWarpTileCell(x, y) and "+" + or scratch:isWaterCell(x, y) and "~" + or scratch:isWalkableCell(x, y) and "." + or " " + end + rows[#rows + 1] = table.concat(row) + end + local tiles, details = visual(scratch) + return { + mapId = map.id, + width = w, + height = h, + rows = rows, + markers = markers, + tileRows = tiles, + tileWidth = tiles and w * 2, + tileHeight = tiles and h * 2, + tileDetailRows = details, + tileDetailWidth = details and w * 4, + tileDetailHeight = details and h * 4, + } +end + +return Overview diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index f21419ae9..ea1a6e542 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -9,6 +9,8 @@ local Logger = require("src.core.Logger") local MapLoader = require("src.world.MapLoader") local Gen3Elevation = require("src.world.Gen3Elevation") local Runtime = require("src.mods.Runtime") +local WorldOverview = require("src.mods.WorldOverview") +local MapOverview = require("src.world.MapOverview") local WorldAPI = {} WorldAPI.__index = WorldAPI @@ -58,6 +60,19 @@ function WorldAPI:current() elevation) } end +-- Detached, zero-based terrain and marker grids for companion UIs/minimaps. +function WorldAPI:mapOverview() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return MapOverview.build(self.game, ow) +end + +-- Table distribution before per-step RNG, repel, roamers and roll hooks. +-- Explicit map IDs also work at the title screen without loading that map. +function WorldAPI:effectiveEncounters(mapId, terrain, opts) + return WorldOverview.encounters(self.game, self:overworld(), self.overviewContext, mapId, terrain, opts) +end + -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else -- lands the player without one, like a scripted warp. function WorldAPI:warpTo(mapId, x, y, facing, opts) diff --git a/tests/engine/world_overview_api.lua b/tests/engine/world_overview_api.lua new file mode 100644 index 000000000..dab0d78bd --- /dev/null +++ b/tests/engine/world_overview_api.lua @@ -0,0 +1,660 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Map = require("src.world.Map") +local WorldAPI = require("src.world.WorldAPI") +local Encounter = require("src.world.Encounter") +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") +local GameVersion = require("src.core.GameVersion") +local Assets = require("src.render.Assets") +local Loader = require("src.mods.Loader") +local Json = require("src.link.Json") +local savedVersion, savedHooks = GameVersion.get(), Runtime.hooks +local function copy(t, seen) + if type(t) ~= "table" then + return t + end + seen = seen or {} + if seen[t] then + return seen[t] + end + local out = {} + seen[t] = out + for k, v in pairs(t) do + out[copy(k, seen)] = copy(v, seen) + end + return out +end +-- Exact reference/key/metatable freeze; cycles (Game -> world -> Game) are real. +local function freeze(root) + local graph = {} + local function walk(t) + if type(t) ~= "table" or graph[t] then + return + end + local keys = {} + graph[t] = { keys = keys, mt = getmetatable(t) } + for k, v in pairs(t) do + keys[k] = v + walk(k) + walk(v) + end + end + walk(root) + return function(label) + for t, before in pairs(graph) do + T.eq(getmetatable(t), before.mt, label .. " metatable") + for k, v in pairs(before.keys) do + T.eq(rawget(t, k), v, label .. " value") + end + for k, v in pairs(t) do + T.eq(before.keys[k], v, label .. " no new key") + end + end + end +end +local function pack(...) + local out = {} + for _, n in ipairs({ ... }) do + out[#out + 1] = string.char(n % 256, math.floor(n / 256)) + end + return table.concat(out) +end +local function uniform(n) + local out = {} + for i = 1, 16 do + out[i] = n + end + return out +end +local function scene(version) + GameVersion.set(version) + local gen = GameVersion.generation() + local def = { + id = "ACTIVE", + width = 2, + height = 2, + borderBlock = 0, + blocks = { 1, 2, 3, 4 }, + warps = { { x = 1, y = 1 }, { x = -1, y = 1 } }, + signs = { + { x = 0, y = 1, item = "POTION", eventFlag = "FOUND" }, + { + x = 1, + y = 0, + item = "POTION", + eventFlag = "HIDDEN", + }, + }, + } + local ts = { + blockCells = 2, + blockTiles = 4, + blocks = { uniform(0), uniform(0), uniform(1), uniform(2), uniform(3) }, + walkable = { 0, 2 }, + waterTiles = { 1 }, + shoreTiles = {}, + warpTiles = { 3 }, + image = "overview-test.png", + tilesPerRow = 4, + } + if gen == 2 then + ts.collision = { 255, 255, 255, 255, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3 } + elseif gen >= 3 then + def.blocks = pack(1 + 1024, 2 + 4096, 3, 4) + def.collisionCells = { 0, 0, 1, 0 } + def.elevationCells = gen == 3 and { 3, 1, 3, 3 } or nil + ts.blockCells, ts.blockTiles, ts.blocks, ts.collision = 1, 2, nil, { 0, 0, 1, 2, 3 } + ts.behaviourBytes = true + ts.warpsAreEvents = true + if gen >= 4 then + def.generation = 4 + def.gen5 = gen == 5 or nil + def.behaviorCells = string.char(0, gen == 5 and 0x3D or 0x10, 0, 0) + end + end + local map = Map.new(def, ts) + local ow = { + isOverworld = true, + map = map, + npcs = { + { def = { item = "POTION" }, cellX = 0, cellY = 0 }, + { def = { item = "POTION" }, cellX = 1, cellY = 0, hidden = true }, + { def = { item = "POTION", fruitTree = true }, cellX = 0, cellY = 1 }, + { def = { item = 0 }, cellX = 0, cellY = 1 }, + { def = { item = "POTION" }, cellX = 99, cellY = 99 }, + }, + tod = "NITE", + player = { cellX = 0, cellY = 0 }, + } + local game = { + data = { + maps = { ACTIVE = def }, + field = { hiddenItems = { ACTIVE = { { x = 0, y = 0 }, { x = 1, y = 1 } } } }, + }, + save = { flags = { HIDDEN = true }, hiddenTaken = { ACTIVE_1_1 = true } }, + overworld = ow, + stack = { states = { ow, { isMenu = true } } }, + } + ow.game = game + return WorldAPI.new(game, "probe"), game, ow +end +local savedLoveRandom, savedMathRandom = love.math.random, math.random +local draws = 0 +love.math.random = function() + draws = draws + 1 + error("query consumed love RNG") +end +math.random = function() + draws = draws + 1 + error("query consumed math RNG") +end +Runtime.hooks = Hooks.new() +local callbacks = 0 +for _, name in ipairs({ "encounter.roll", "encounter.species", "encounter.table" }) do + Runtime.hooks:wrap(name, function() + callbacks = callbacks + 1 + error("query executed encounter hook") + end) +end +for _, version in ipairs({ + "red", + "gold", + "silver", + "crystal", + "emerald", + "firered", + "platinum", + "heartgold", + "black", + "white", +}) do + local api, game, ow = scene(version) + for _, warm in ipairs({ false, true }) do + if warm then + ow.map:blockAt(0, 0) + end + local stable = freeze(game) + local out = assert(api:mapOverview()) + stable(version .. " overview") + T.eq(out.mapId, "ACTIVE", version .. " map ID") + T.eq(out.width, ow.map.widthCells, version .. " width") + T.eq(#out.rows, out.height, version .. " rows") + -- Native classifiers on an independent map give the exact terrain contract. + local native = Map.new(copy(ow.map.def), copy(ow.map.tileset)) + for y = 0, out.height - 1 do + T.eq(#out.rows[y + 1], out.width, version .. " row width") + for x = 0, out.width - 1 do + local expected = native:isWarpTileCell(x, y) and "+" + or native:isWaterCell(x, y) and "~" + or native:isWalkableCell(x, y) and "." + or " " + T.eq(out.rows[y + 1]:sub(x + 1, x + 1), expected, version .. " native terrain") + end + end + T.same(out.markers, { + { kind = "warp", x = 1, y = 1 }, + { kind = "item", x = 0, y = 0 }, + { kind = "hidden", x = 0, y = 0 }, + { + kind = "hidden", + x = 0, + y = 1, + }, + }, version .. " only active markers") + out.markers[1].x = 999 + out.rows[1] = "corrupted" + T.eq(api:mapOverview().markers[1].x, 1, version .. " detached markers") + end + local old = assert(api:mapOverview()) + ow.npcs = {} + game.save.flags.FOUND = true + game.save.hiddenTaken.ACTIVE_0_0 = true + T.eq(#api:mapOverview().markers, 1, version .. " live removals reflected") + T.eq(#old.markers, 4, version .. " old snapshot remains detached") + ow.map:setBlock(0, 0, 0, true) + local stable = freeze(game) + local patched = assert(api:mapOverview()) + stable(version .. " patched map") + T.eq(patched.rows[1]:sub(1, 1), " ", version .. " live shut cell") + if GameVersion.generation() >= 4 then + ow.map.def.behaviorCells = nil + local result, why = api:mapOverview() + T.eq(result, nil, version .. " streamed raster refused") + T.check(why:find("streamed", 1, true), version .. " streamed reason") + end +end +-- Literal terrain reference: whitespace is an impassable cell, not a missing row. +do + local api = scene("red") + T.same(api:mapOverview().rows, { "..~~", "..~~", "..++", "..++" }, "GB literal terrain grid") +end +-- GB visuals use actual 8x8 pixel means/quadrants; no placeholder art on failure. +do + local api, game, ow = scene("gold") + local saved = love.image + love.image = {} + local reads = 0 + love.image.newImageData = function(path) + reads = reads + 1 + T.eq(path, "overview-test.png", "resolved image path") + return { + getDimensions = function() + return 32, 8 + end, + getPixel = function(_, x, y) + local shade = math.floor(x / 8) + if shade == 2 then + shade = (x % 8 < 4 and 0 or 3) + end + local v = 1 - shade / 3 + return v, v, v, 1 + end, + } + end + Assets.invalidate() + local stable = freeze(game) + local out = assert(api:mapOverview()) + stable("visual pure") + T.eq(out.tileRows[1], "00001111", "literal GB tile shades") + T.eq(out.tileRows[5], "22223333", "literal averaged split tile") + T.eq(out.tileDetailRows[9], "0303030333333333", "literal GB quadrants") + T.eq(out.tileWidth, 8, "2x tile width") + T.eq(out.tileDetailWidth, 16, "4x detail width") + out.tileRows[1] = "broken" + out.tileDetailRows[1] = "broken" + T.eq(api:mapOverview().tileRows[1], "00001111", "detached visual rows") + T.eq(reads, 1, "visual cache reused") + Assets.invalidate() + api:mapOverview() + T.eq(reads, 2, "asset reload invalidates visual cache") + love.image.newImageData = function() + error("missing image") + end + Assets.invalidate() + T.eq(api:mapOverview().tileRows, nil, "unavailable art is omitted") + love.image = saved + Assets.invalidate() +end +local function tableDef(rate, buckets, species) + local slots = {} + for i, id in ipairs(species or { "RATTATA", "PIDGEY", "RATTATA" }) do + slots[i] = { species = id, min = 2, max = 7 } + end + return { rate = rate, buckets = buckets or { 51, 102, 256 }, slots = slots } +end +local function encounters(version) + GameVersion.set(version) + local a = tableDef(60) + a.byTime = { + morn = tableDef(80, nil, { "PIDGEY", "PIDGEY", "RATTATA" }), + nite = tableDef(20, nil, { "HOOTHOOT", "RATTATA", "HOOTHOOT" }), + } + local maps = { + A = { id = "A", label = "RouteA", encounters = GameVersion.generation() >= 4 and 7 or nil }, + B = { + id = "B", + }, + } + local data = { + maps = maps, + encounters = { + [maps.A.encounters or "A"] = { + grass = a, + water = tableDef(10, { 128, 256 }, { "MAGIKARP", "POLIWAG" }), + }, + }, + field = {}, + pokemon = { + RATTATA = { name = "Rattata" }, + PIDGEY = { name = "Pidgey" }, + HOOTHOOT = { name = "Hoothoot" }, + MAGIKARP = { name = "Magikarp" }, + POLIWAG = { name = "Poliwag" }, + }, + } + local game = { data = data, save = { party = {}, flags = {} }, stack = { states = {} } } + return WorldAPI.new(game, "probe"), game, a +end +local function nativeParity(api, game, opts) + local hour = opts.daytime == "NITE" and 22 or opts.daytime == "MORNING" and 6 or 12 + local enc = Encounter.forMap(game.data, game.data.maps.A, "A", hour, game.save) + local slots = Encounter.atTime(enc.grass, opts.daytime) + local counts = {} + Encounter.load(game.data) + for pick = 0, (slots.bucketSpan or 256) - 1 do + local n = 0 + local result = Encounter.chooseTable(slots, function(lo) + n = n + 1 + if n == 1 then + return pick + end + return lo + end) + if result then + counts[tostring(result.species)] = (counts[tostring(result.species)] or 0) + 1 + end + end + local stable = freeze(game) + local out = assert(api:effectiveEncounters("A", "grass", opts)) + stable("encounter graph") + T.same(out.dist, counts, "native exhaustive bucket distribution") + local passed = 0 + for gate = 0, (slots.rateMax or 256) - 1 do + local n = 0 + local result = Encounter.rollTable(slots, function(lo) + n = n + 1 + if n == 1 then + return gate + end + return lo + end) + if result then + passed = passed + 1 + end + end + T.eq(out.chance, passed / (slots.rateMax or 256), "native exhaustive rate gate") + return out +end +for _, version in ipairs({ "red", "gold", "silver", "crystal", "emerald", "firered" }) do + local api, game = encounters(version) + for _, period in ipairs({ "MORNING", "DAY", "NITE" }) do + nativeParity(api, game, { daytime = period }) + end + local out = api:effectiveEncounters("A", "grass", { daytime = "DAY" }) + T.same(out.dist, { RATTATA = 205, PIDGEY = 51 }, version .. " duplicate species aggregated") + out.dist.RATTATA = 0 + T.eq(api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.RATTATA, 205, "distribution detached") + T.same( + api:effectiveEncounters("A", "indoor", { daytime = "NITE" }), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "indoor grass alias" + ) + T.same( + api:effectiveEncounters("A", "water", { daytime = "NITE" }).dist, + { MAGIKARP = 128, POLIWAG = 128 }, + "water table" + ) + T.same(api:effectiveEncounters("B", "grass"), { chance = 0, dist = {} }, "known map with no encounters") + T.eq(api:effectiveEncounters("missing", "grass"), nil, "unknown map refused") + for _, bad in ipairs({ "fishing", "darkGrass", false }) do + T.eq(api:effectiveEncounters("A", bad), nil, "terrain refused") + end + T.eq(api:effectiveEncounters("A", "grass", "NITE"), nil, "options must be table") + T.eq(api:effectiveEncounters("A", "grass", { daytime = "dusk" }), nil, "invalid time refused") + T.same( + api:effectiveEncounters("A", "grass", { daytime = "night" }), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "time aliases" + ) + game.data.encounters = nil + T.eq(api:effectiveEncounters("A", "grass"), nil, "unloaded data refused") +end +-- Gen2 outbreaks use the same native map selection for all three cartridges. +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local api, game = encounters(version) + game.data.field.gen2Swarms = { + maps = { RouteA = { grass = tableDef(200, nil, { "HOOTHOOT", "HOOTHOOT", "HOOTHOOT" }) } }, + flags = { [0] = 17 }, + } + game.save.g2Swarm = { kind = 0, map = "A" } + if version == "crystal" then + game.save.flags[require("src.script.Gen2Flags").scriptFlag(17)] = true + end + nativeParity(api, game, { daytime = "DAY" }) + T.same( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist, + { HOOTHOOT = 256 }, + version .. " outbreak table" + ) + game.save.g2Swarm = nil + T.eq( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.RATTATA, + 205, + "outbreak ends immediately" + ) +end +-- DS rates/bucket spans, time substitutions, daily replacements and seasons. +for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do + local api, game = encounters(version) + local slots = {} + for i, chance in ipairs({ 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }) do + slots[i] = { species = i % 2 == 0 and "PIDGEY" or "RATTATA", level = 5, chance = chance } + end + local raw = { + grassRate = 30, + grass = slots, + surf = { + rate = 20, + slots = { { species = "MAGIKARP", level = 5, chance = 100 } }, + }, + } + game.data.encounters[7] = raw + if version == "black" or version == "white" then + raw.family = "bw" + raw.seasons = + { winter = { grassRate = 50, grass = { { species = "HOOTHOOT", level = 5, chance = 100 } } } } + game.save.gen5Season = 3 + end + for _, period in ipairs({ "MORNING", "DAY", "NITE" }) do + nativeParity(api, game, { daytime = period }) + end + if raw.family == "bw" then + T.same(api:effectiveEncounters("A", "grass").dist, { HOOTHOOT = 100 }, "BW current season") + game.save.gen5Season = 0 + T.eq(api:effectiveEncounters("A", "grass").chance, 0.3, "BW season changes immediately") + end +end +-- Cold DS resolver views: timed slots and daily replacements are reflected +-- without initializing save state or exposing mutable source slot records. +for _, version in ipairs({ "platinum", "heartgold" }) do + local api, game = encounters(version) + local slots = {} + for i, chance in ipairs({ 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }) do + slots[i] = { species = "RATTATA", level = 5, chance = chance } + end + local raw = { grassRate = 30, grass = slots, day = { 25, 25 }, night = { 163, 163 }, swarm = { 129, 129 } } + if version == "heartgold" then + raw.timed = { day = {}, night = {} } + for i = 1, 12 do + raw.timed.day[i] = 25 + raw.timed.night[i] = 163 + end + end + game.data.pokemon[25] = { name = "Pikachu" } + game.data.pokemon[163] = { name = "Hoothoot" } + game.data.pokemon[129] = { name = "Magikarp" } + game.data.encounters[7] = raw + local stable = freeze(game) + local day = assert(api:effectiveEncounters("A", "grass", { daytime = "DAY" })) + local night = assert(api:effectiveEncounters("A", "grass", { daytime = "NITE" })) + T.eq(day.dist.PIKACHU, version == "heartgold" and 100 or 20, "DS time species day") + T.eq(night.dist.HOOTHOOT, version == "heartgold" and 100 or 20, "DS time species night") + stable("cold DS timed query") + local Swarms = require("src.world.Gen4Swarms") + game.data.maps.A.header = Swarms.HEADERS[1] + game.save.gen4Swarm = { enabled = true, daily = 0, rand = 123 } + stable = freeze(game) + local outbreak = assert(api:effectiveEncounters("A", "grass", { daytime = "DAY" })) + T.eq(outbreak.dist.MAGIKARP, 40, "DS daily swarm slots") + stable("DS daily swarm read") + outbreak.dist.MAGIKARP = 0 + T.eq( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.MAGIKARP, + 40, + "DS distribution detached" + ) + game.save.gen4Swarm.enabled = false + if version == "platinum" then + game.data.maps.A.header = 287 + game.save.pokedex = { national = true } + game.save.gen4TrophyGarden = { slot1 = 0, slot2 = 1 } + game.data.gen4_special_encounters = { trophyGarden = { 25, 163 } } + stable = freeze(game) + local garden = assert(api:effectiveEncounters("A", "grass", { daytime = "MORNING" })) + T.eq(garden.dist.PIKACHU, 5, "garden first daily slot") + T.eq(garden.dist.HOOTHOOT, 5, "garden second daily slot") + stable("DS garden read") + end +end +-- A water table is not time-swapped by the native step path. +do + local api, game = encounters("gold") + game.data.encounters.A.water.byTime = { nite = tableDef(255, { 256 }, { "HOOTHOOT" }) } + T.same( + api:effectiveEncounters("A", "water", { daytime = "NITE" }).dist, + { MAGIKARP = 128, POLIWAG = 128 }, + "water ignores land time selector" + ) +end +-- Numeric IDs follow the shared public name contract, including loader aliases. +do + local api, game = encounters("platinum") + game.data.pokemon[25] = { id = 25, name = "Pikachu" } + game.data.encounters[7] = { grass = tableDef(50, { 256 }, { 25 }) } + T.same(api:effectiveEncounters("A", "grass").dist, { PIKACHU = 256 }, "numeric species -> portable ID") +end +-- Default Gen2 period follows the clock, and queries never run world.tod hooks. +do + local api, game = encounters("gold") + local date = os.date + os.date = function(format) + if format == "%H" then + return "23" + end + return date(format) + end + T.same( + api:effectiveEncounters("A", "grass"), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "live Gen2 clock" + ) + Runtime.hooks:wrap("world.tod", function() + callbacks = callbacks + 1 + end) + game.overworld = { isOverworld = true, map = { id = "A" }, tod = "MORNING" } + game.stack.states = { game.overworld } + T.same( + api:effectiveEncounters("A", "grass"), + api:effectiveEncounters("A", "grass", { daytime = "MORNING" }), + "cached effective mod period" + ) + os.date = date +end +-- Malformed tables and exceptional positional tables fail explicitly. +do + local api, game, a = encounters("red") + for _, bad in ipairs({ 0, -1, math.huge, 0 / 0 }) do + a.rateMax = bad + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid denominator") + end + a.rateMax = nil + a.buckets = { 51, 50, 256 } + T.eq(api:effectiveEncounters("A", "grass"), nil, "descending buckets refused") + a.buckets = { 51, 102, 257 } + T.eq(api:effectiveEncounters("A", "grass"), nil, "unreachable bucket refused") + a.buckets = "bad" + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid bucket type refused") + a.buckets = { 51, 102, 256 } + local originalSlots = a.slots + a.slots = "bad" + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid slot type refused") + a.slots = originalSlots + a.buckets = { 51, 102, 256 } + a.rate = 0 + T.same(api:effectiveEncounters("A", "grass"), { chance = 0, dist = {} }, "zero rate") + a.rate = 999 + T.eq(api:effectiveEncounters("A", "grass").chance, 1, "saturated rate") + GameVersion.set("heartgold") + local S = require("src.world.HgssSafariZone") + game.data.maps.A.layout = S.LAYOUT + game.data.maps.A.cameraFamily = "hgss" + local stable = freeze(game) + T.eq(api:effectiveEncounters("A", "grass"), nil, "Safari query refused") + stable("Safari does not seed save") + GameVersion.set("emerald") + for _, id in ipairs({ + require("src.script.Gen3BattlePike").WILD_ROOM, + require("src.script.Gen3BattlePyramid").FLOOR, + }) do + game.data.maps[id] = { id = id } + T.eq(api:effectiveEncounters(id, "grass"), nil, "facility query refused") + end +end +-- API1/2 native mod loading, lazy capability queries and live table patches. +for _, apiVersion in ipairs({ 1, 2 }) do + GameVersion.set("gold") + local data = T.fixtures.fresh() + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = apiVersion, + entry = "main.lua", + content_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" + or nil, + callback_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" + or nil, + script_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" or nil, + }), + ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + elseif files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function(self) + resolves = resolves + 1 + return self.game + end + T.check(loader:load(data), "real loader succeeds") + local mod = loader.apis.probe + if os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 then + T.eq(mod.contentFormat, "unified", "unified content facade") + T.eq(mod.callbackFormat, "unified", "unified callback facade") + T.eq(mod.scriptFormat, "unified", "portable script facade") + end + T.eq(resolves, 0, "entry and ID context stay lazy") + if mod.supports then + T.eq(mod:supports("world", "mapOverview"), true, "overview capability") + T.eq(mod:supports("world", "effectiveEncounters"), true, "encounter capability") + T.eq(resolves, 0, "capability query stays lazy") + end + loader.game = { data = data, save = {}, stack = { states = {} } } + local world = mod.world + T.eq(world:mapOverview(), nil, "title screen overview is quiet") + local out = assert(world:effectiveEncounters("FIX_ROUTE", "grass")) + T.check(next(out.dist) ~= nil, "query available at title screen") + local before = out.chance + local id = next(out.dist) + data.pokemon[id].name = "Renamed presentation" + T.check( + world:effectiveEncounters("FIX_ROUTE", "grass").dist[id] ~= nil, + "public identity survives name patch" + ) + data.encounters.FIX_ROUTE.grass.rate = 100 + T.check(world:effectiveEncounters("FIX_ROUTE", "grass").chance ~= before, "live data patch reflected") +end +T.eq(draws, 0, "all queries consume zero RNG draws") +T.eq(callbacks, 0, "all queries execute zero hook callbacks") +love.math.random, math.random = savedLoveRandom, savedMathRandom +Runtime.hooks = savedHooks +GameVersion.set(savedVersion) +T.finish("world overview and encounter API") From 69c3474958ec09b48b91b55ff8cfd0ed15501d46 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:55:26 +0200 Subject: [PATCH 12/71] Add shared API 3 entry and pinned cross-engine contracts --- .github/workflows/api3-contract.yml | 56 ++++ docs/api3-portability.md | 145 ++++++++++ docs/modding.md | 17 +- src/core/Version.lua | 2 +- src/mods/Api3.lua | 199 ++++++++++++++ src/mods/Api3Catalog.lua | 57 ++++ src/mods/Api3Records.lua | 59 ++++ src/mods/CallbackRecords.lua | 79 +++++- src/mods/Loader.lua | 9 +- src/mods/Manifest.lua | 12 +- src/mods/UnifiedCallbacks.lua | 14 +- tests/api3_contract/mods/portable/main.lua | 110 ++++++++ .../api3_contract/mods/portable/manifest.json | 1 + tests/api3_contract/mods/rollback/main.lua | 6 + .../api3_contract/mods/rollback/manifest.json | 1 + tests/api3_contract/run.lua | 256 ++++++++++++++++++ tests/api3_contract/worker.lua | 10 + tests/engine/api3_compat.lua | 226 ++++++++++++++++ tests/engine/api3_entry.lua | 16 ++ tools/api3-contract-check/check.py | 82 ++++++ tools/modkit.py | 34 ++- 21 files changed, 1357 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/api3-contract.yml create mode 100644 docs/api3-portability.md create mode 100644 src/mods/Api3.lua create mode 100644 src/mods/Api3Catalog.lua create mode 100644 src/mods/Api3Records.lua create mode 100644 tests/api3_contract/mods/portable/main.lua create mode 100644 tests/api3_contract/mods/portable/manifest.json create mode 100644 tests/api3_contract/mods/rollback/main.lua create mode 100644 tests/api3_contract/mods/rollback/manifest.json create mode 100644 tests/api3_contract/run.lua create mode 100644 tests/api3_contract/worker.lua create mode 100644 tests/engine/api3_compat.lua create mode 100644 tests/engine/api3_entry.lua create mode 100644 tools/api3-contract-check/check.py diff --git a/.github/workflows/api3-contract.yml b/.github/workflows/api3-contract.yml new file mode 100644 index 000000000..fe5b47952 --- /dev/null +++ b/.github/workflows/api3-contract.yml @@ -0,0 +1,56 @@ +name: API 3 contracts +on: + pull_request: + paths: + - '.github/workflows/api3-contract.yml' + - 'src/**' + - 'tests/api3_contract/**' + - 'tests/engine/api3_*.lua' + - 'tools/api3-contract-check/**' + push: + branches: [main, dev] + paths: + - '.github/workflows/api3-contract.yml' + - 'src/**' + - 'tests/api3_contract/**' + - 'tests/engine/api3_*.lua' + - 'tools/api3-contract-check/**' +permissions: + contents: read +concurrency: + group: api3-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + compare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + path: gen2 + sparse-checkout: | + src + data + tests + tools/api3-contract-check + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: gen1-reference + sparse-checkout: | + src + data + tests + assets/fonts + - name: install LuaJIT + run: sudo apt-get update && sudo apt-get install -y luajit + - name: identical mod contracts on both engines + run: >- + python3 gen2/tools/api3-contract-check/check.py + --gen1 gen1-reference --gen2 gen2 + --report "${{ runner.temp }}/api3-report.json" + - uses: actions/upload-artifact@v7 + with: + name: api3-contract-report + path: ${{ runner.temp }}/api3-report.json + if-no-files-found: error diff --git a/docs/api3-portability.md b/docs/api3-portability.md new file mode 100644 index 000000000..52f592613 --- /dev/null +++ b/docs/api3-portability.md @@ -0,0 +1,145 @@ +# Shared API 3 entry + +Use the same manifest and entry file on Gen1Recomp and Gen2Recomped: + +```json +{ + "id": "portable_example", + "name": "Portable Example", + "version": "1.0.0", + "api": 3, + "games": ["gen1", "gen2", "gen3"], + "entry": "main.lua" +} +``` + +```lua +local mod = ... +if mod.content.moves:has("TACKLE") then + local ok, reason = mod.content.moves:patch("TACKLE", {power = 45}) + if not ok then mod.log:warn("%s", reason) end +end +``` + +Create a portable starter with: + +```sh +python3 tools/modkit.py scaffold portable_example --api 3 +``` + +It generates the shared games list and a guarded canonical move patch. It +omits an engine-release range because Gen1 and Gen2 have independent engine +version numbers. The default scaffold remains API 2 with its existing range +and profile templates. The `--api` value is now honored instead of silently +emitting API 2 for every value. + +API 3 automatically selects unified content, callbacks and portable scripts. +No `content_format`, `callback_format` or `script_format` fields are needed. +An explicit `"native"` for one of those fields is an error under API 3; +`"unified"` is accepted. API 1/2 retain native defaults, and API 2 retains +its existing individual opt-ins. The engine advertises `Version.modApi = 3`. +A higher unknown API remains a load error. + +This is a common entry and a tested compatibility subset, not a claim that +all of Gen1Recomp's services have been ported. It builds on PRs #90–#99. +The existing unified-content limitations still apply: unsupported writes +return `nil, reason` before recording an operation, native diagnostics are +read-only, and ordinary schema errors raise an attributed load error. +Entry failure rolls back the mod's content and subscriptions. Content is +frozen after loading. + +## Public contracts + +Core `pokemon`, `moves`, `items`, `trainers` and `encounters` handles use the +existing unified adapters. Other registries retain their native record +shapes, rather than inventing canonical fields. Every content handle exposes +`has(id)`, `supports()` and `available`. API-3 passthrough reads and iteration +return detached values too; change data through the write methods. + +The API-3 Pokemon boundary uses short statuses (`SLP`, `PSN`, `BRN`, `FRZ`, +`PAR`, `TOX`), nickname-first display names and a common Special alias. +Writing `stats.special` in a writable callback sets both later Special stats; +contradictory split values are rejected before commit. API-2 unified status +names and callback behavior are unchanged. DVs/shiny presentation is read-only +in the callback boundary. Party and battle reads remain detached snapshots. + +Capture `mod.world` and `mod.battle` at entry time. World methods can be called +before Game exists and return `nil, reason`; reading the facade never boots +Game. The same world handle follows the loader's current Game/save instead of +retaining an earlier one. Battle snapshots and intents retain their existing +revision/replay validation. + +The common world calls currently enabled for generations 1–3 are: + +- `overworld`, `current`, `party`, `activeBlockAt`, `liveMaps`, `mapOverview`, + `effectiveEncounters`; +- `canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, + `canFly`, `flyTo`; +- `getFlag`/`setFlag` for private `mod:` boolean flags; +- `queueScript` for the existing bounded portable script language. + +Check `mod:supports("world", name)` before relying on a call. The other +Gen1 world method names remain callable but explicitly return +`nil, "unsupported: ..."`: warp/object/block mutations, runtime NPC operations, +`startWildBattle` and `invalidateMap` have not yet been verified against the +Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native +symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. +Gen4/5 do not claim the shared world surface in this first entry PR. + +Private flags use `save.modData[modId].worldFlags`, independently of native +flags and other mods. A read does not create a bucket. Clearing removes the +stored key. A change emits `flag.changed` with +`name = "mod.."` and the boolean value; setting the same value +emits nothing. Invalid save stores are refused without overwriting them. + +Gen1 API-3 callback names from the pinned reference are recognized even when +this engine has no call site for them. Registration is allowed; availability +still requires a native call site in the selected version. For example, +`render.output` can be subscribed to but reports unavailable here. Unknown +names remain errors. An accepted subscription is not proof that a feature +runs. Source-specific engine handles in lifecycle/UI payloads remain native. + +For scripts, use `mod:supports("scripts", verb)` to test Gen2Recomped's current +bounded vocabulary. A `queueScript` method does not imply support for every +Gen1 script verb. See [portable world scripts](portable-world-scripts.md). + +## Reproducible contract checks + +```sh +luajit tests/engine/api3_entry.lua +luajit tests/engine/api3_compat.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The external reference must be a clean checkout at +`bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. +Use a separate checkout; do not reset a working installation. No Gen1 engine +implementation is vendored into this repository. Only public callback names +are recorded as a compatibility catalog. + +The exact same manifest and mod entry run on both engines for Red, Blue, +Yellow, Gold, Silver, Crystal, FireRed and Emerald, in both success and +intentional-rollback cases. Each engine/version/case has its own LuaJIT +process. The shared harness constructs native-shaped synthetic datasets; +the mod fixtures contain no engine imports or engine-specific branches. +The runner also generates an API-3 mod through the real scaffold CLI and +loads those same generated files on both engines for each version. This adds +eight comparisons to the sixteen normal/rollback cases: 24 comparisons and +48 isolated loader runs. It compares JSON projections of public results and +records hashes of both shared and generated fixture files. It refuses a different or modified reference. + +Coverage includes canonical content reads/writes/iteration and native merge +results, detached core and passthrough reads, callable early handles, later +Game replacement, party fields, private flags, Gen1 battle snapshot/invalid-intent boundaries, callback payloads and native +writeback, custom events, unsupported-name handling, frozen content and failed +owner cleanup. Separate Gen2 tests check API-1/2 behavior, contradictory +manifest fields, honest availability and malformed flag stores. CI pins the +same external commit and uploads the report. + +These are ROM-free loader/runtime-boundary tests, not retail-ROM gameplay +or a proof of every exposed native callback's payload. Native action details +remain covered by the prerequisite PR suites. Extend these shared fixtures +whenever another API-3 service becomes portable; do not make a failing +comparison pass by changing only one engine's mod fixture. diff --git a/docs/modding.md b/docs/modding.md index 85a7401dc..a48460651 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -18,6 +18,12 @@ Regenerate the reference straight into a wiki checkout: luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +## Shared API 3 entry + +The [API-3 entry and cross-engine contracts](api3-portability.md) describe +using the same manifest and mod source on Gen1Recomp and Gen2Recomped. +API 3 selects the unified adapters automatically; API 1/2 keep their defaults. + ## Feature queries (`mod:supports`) `mod:supports(surface, name)` returns `true` when the selected host exposes an @@ -62,11 +68,12 @@ Oak-speech callbacks apply to Gen 1–2 except Prism; Birch-speech events apply to Emerald. Other versions use their own intros. An event still needs its trigger to occur; custom events also need another mod to emit them. -This is additive to Mod API 2: it neither normalizes content records, IDs or -callback payloads across generations nor provides Gen1Recomp's unified API 3. -Existing registration/subscription behavior remains unchanged. When adding a -native callback or changing screen routing, update `src/mods/Capabilities.lua` -and the capability tests in the same change. +For API 1/2, the query describes the existing interfaces and does not itself +normalize records, IDs or callback payloads. API 3 selects the portable +adapters described in [Shared API 3 entry](api3-portability.md), with explicit +limits on availability. Existing API-1/2 registration/subscription behavior +remains unchanged. When adding a native callback or changing screen routing, +update `src/mods/Capabilities.lua` and the capability tests in the same change. Additional read-only world summaries: [map overview and encounter queries](world-overview.md). diff --git a/src/core/Version.lua b/src/core/Version.lua index 82cad2f61..b7ba8b6df 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -16,7 +16,7 @@ local Version = { -- binary (e.g. a LOVE version bump); an older shell -- refuses to chainload a payload whose minShell -- exceeds the shell it provides. - modApi = 2, -- mod API major (manifest `api`) + modApi = 3, -- mod API major (manifest `api`) linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL) saveFormat = 4, -- save.meta.format cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua new file mode 100644 index 000000000..f5de4a039 --- /dev/null +++ b/src/mods/Api3.lua @@ -0,0 +1,199 @@ +-- API-3 entry over the existing native adapters. Unsupported operations have +-- callable handles but never delegate to a native method with a different contract. +local Records = require("src.mods.Api3Records") +local Runtime = require("src.mods.Runtime") +local copy = require("src.mods.Merge").deepCopy +local A = {} +local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty + availableFieldActions useFieldAction canFly flyTo mapOverview warpTo toggleObject + setFlag getFlag effectiveEncounters replaceBlock spawnNpc liveMaps removeNpc npc + queueScript startWildBattle invalidateMap]] +local SUPPORTED = {} +for name in + ([[overworld current activeBlockAt party canReorderParty reorderParty + availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters + setFlag getFlag liveMaps queueScript]]):gmatch("%S+") +do + SUPPORTED[name] = true +end +local function pack(...) + return { n = select("#", ...), ... } +end +local unpack = table.unpack or unpack +local function block(gen) + return { + tileSize = gen == 3 and 16 or 32, + cellSize = 16, + coordinateBase = 0, + kind = gen == 3 and "metatile" or "block", + } +end +function A.install(api, loader, readContext, overviewContext, engineRequire) + local gen = api.generation + local supportedGeneration = gen >= 1 and gen <= 3 + -- Passthrough registries keep native shapes but expose the same handle metadata. + for name, entry in pairs(api.content) do + local handle = entry + if type(handle) == "table" and handle.supports == nil then + local available, reason = api:supports("content", name) + handle.available = available + local get, each = handle.get, handle.each + if get then + handle.get = function(self, id) + local value, why = get(self, id) + return copy(value), why + end + end + if each then + handle.each = function(self) + local iter, state, key = each(self) + return function() + local id, value = iter(state, key) + key = id + return id, copy(value) + end + end + end + handle.supports = function() + return available, reason + end + end + if type(handle) == "table" and handle.has == nil and handle.get then + handle.has = function(_, id) + return handle:get(id) ~= nil + end + end + end + local scripts = api.world -- PortableScripts owns the per-loader/session queue. + local world = {} + local nativeGame, nativeWorld + local function current() + return loader:_game(true) -- Never bootstrap Game for an observational early call. + end + local function native(game) + if game ~= nativeGame then + nativeWorld = engineRequire("src.world.WorldAPI").new(game, api.id, readContext) + nativeWorld.overviewContext = overviewContext + nativeGame = game + end + return nativeWorld + end + local function dispatch(name, ...) + if not supportedGeneration or not SUPPORTED[name] then + return nil, "unsupported: API 3 world method " .. name + end + local game = current() + if not game then + return nil, "no game" + end + if name == "setFlag" or name == "getFlag" then + if not game.save then + return nil, "no save" + end + local key, value = ... + if type(key) ~= "string" or key:sub(1, 4) ~= "mod:" then + return nil, "unsupported: API 3 native flag mapping" + end + if name == "setFlag" and type(value) ~= "boolean" then + return nil, "flag value must be boolean" + end + local id = key:sub(5) + if #id == 0 or #id > 256 or id:find("%c") then + return nil, "invalid mod flag" + end + local store = game.save.modData + if store ~= nil and type(store) ~= "table" then + return nil, "invalid mod save store" + end + local bucket = store and store[api.id] + if bucket ~= nil and type(bucket) ~= "table" then + return nil, "invalid mod save store" + end + local flags = bucket and bucket.worldFlags + if flags ~= nil and type(flags) ~= "table" then + return nil, "invalid mod flag store" + end + if name == "getFlag" then + return flags ~= nil and flags[id] == true + end + local before = flags ~= nil and flags[id] == true + game.save.modData = store or {} + bucket = bucket or {} + game.save.modData[api.id] = bucket + flags = flags or {} + bucket.worldFlags = flags + flags[id] = value or nil + if before ~= value then + Runtime.emit("flag.changed", { name = "mod." .. api.id .. "." .. id, value = value }) + end + return true + end + if name == "queueScript" then + return scripts:queueScript(...) + end + local raw = native(game) + local result = pack(raw[name](raw, ...)) + if name == "party" and type(result[1]) == "table" then + for _, mon in ipairs(result[1]) do + Records.mon(mon, game.data, gen) + end + elseif (name == "current" or name == "mapOverview") and type(result[1]) == "table" then + result[1].block = block(gen) + end + return unpack(result, 1, result.n) + end + for name in WORLD:gmatch("%S+") do + local method = name + world[method] = function(_, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local values = pack(pcall(dispatch, method, ...)) + Runtime.currentMod = owner + if not values[1] then + return nil, "world operation failed: " .. tostring(values[2]) + end + return unpack(values, 2, values.n) + end + end + api.world = world + local battle = api.battle + api.battle = { + snapshot = function() + local snapshot, why = battle:snapshot() + if not snapshot then + return nil, why + end + local game = current() + local data = game and game.data + Records.mon(snapshot.player, data, gen) + Records.mon(snapshot.enemy, data, gen) + for _, name in ipairs({ "party", "battlers" }) do + for _, mon in ipairs(snapshot[name] or {}) do + Records.mon(mon, data, gen) + end + end + return snapshot + end, + submit = function(_, ...) + return battle:submit(...) + end, + } + -- Version datasets have no native Gen2Recomped adapter yet. Keeping the + -- handle callable allows the same mod to fall back without engine imports. + api.datasets = { + open = function() + return nil, "unsupported: version datasets" + end, + } + local supports = api.supports + function api:supports(surface, name) + if surface == "world" then + if supportedGeneration and SUPPORTED[name] then + return true + end + return false, "unsupported: API 3 world method " .. tostring(name) + end + return supports(self, surface, name) + end +end +return A diff --git a/src/mods/Api3Catalog.lua b/src/mods/Api3Catalog.lua new file mode 100644 index 000000000..0549286b9 --- /dev/null +++ b/src/mods/Api3Catalog.lua @@ -0,0 +1,57 @@ +-- Public callback-name snapshot from Gen1Recomp API 3, commit +-- 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36. Recognition is not availability: +-- Capabilities still requires a native Gen2Recomped call site. +local function names(text) + local out = {} + for name in text:gmatch("%S+") do + out[name] = true + end + return out +end +local C = {} +C.hooks = names([[ +battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.caught_marker_visible +battle.charge_required battle.crit battle.damage battle.enemy_action +battle.enemy_switch_or_item battle.exp_award battle.field_residual battle.low_health_alarm +battle.menu_auxiliary battle.move_grid_navigation battle.overlay battle.run +battle.status_hud_visible battle.style battle.turn_order breeding.compatibility +catch.nickname catch.party_full catch.rate core.logic_speed +core.quit_to_launcher core.update encounter.fishing encounter.roll +encounter.species encounter.table evolution.check exp.gain +fieldmove.eligibility gender.roll held_item.trigger input.gamepad +input.key input.pointer input.step input.wheel +intro.oak_speech.build item.use link.fingerprint map.palette +movement.collision movement.speed music.select music.volume +phone.contact_list player.sprite pokemon.icon pokemon.level_visible +pokemon.sprite render.compose render.hud render.letterbox +render.output render.output_enabled render.viewport render.window +render.zones save.new_game save.write screen.render_visible +script.command shiny.roll trainer.before_battle trainer.party +transition.style ui.list_menu ui.naming.grid ui.options.rows +ui.party.grid_navigation ui.party.submenu ui.pc.items ui.start_menu.items +ui.title_menu.items warp.destination world.follower.spawn world.talk +world.tod zoom.range +]]) +C.events = names([[ +apricorn.converted assets.transformed battle.ball_thrown battle.battler_switched +battle.damage_dealt battle.ended battle.exp_gained battle.fainted +battle.move_used battle.started battle.status_inflicted battle.turn_ended +battle.turn_started breeding.egg_created bug_contest.scored checkpoint.restored +clock.day_changed discord.join_requested egg.hatched flag.changed +game.ready happiness.changed intro.boot.copyright intro.boot.gamefreak +intro.boot.movie intro.boot.movie_ended intro.boot.title intro.oak_speech.answered +intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step link.battle_ended +link.connected link.desync link.ended mail.read +mail.written map.entered map.exited map.reloaded +mod.options_changed mods.loaded music.started music.stopped +phone.call_received player.warped pokemon.before_give pokemon.caught +pokemon.evolved pokemon.level_up pokemon.move_learned pokemon.received +pokerus.infected radio.channel roamer.encountered roamer.moved +save.created save.loaded save.loading save.writing +screen.popped screen.pushed script.ended script.started +sound.played trade.completed unown.unlocked world.blacked_out +world.block_replaced world.boulder_moved world.interacted world.live_maps_preparing +world.live_maps_updated world.npc_spawned world.object_toggled world.stepped +world.tod_changed world.trainer_engaged +]]) +return C diff --git a/src/mods/Api3Records.lua b/src/mods/Api3Records.lua new file mode 100644 index 000000000..22d540b20 --- /dev/null +++ b/src/mods/Api3Records.lua @@ -0,0 +1,59 @@ +-- API-3 public Pokemon values. API-2 unified records keep their existing shape. +local R = {} +local Stats = require("src.pokemon.Stats") +local bit = require("bit") +local LONG = { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local SHORT = {} +for short, long in pairs(LONG) do + SHORT[long], SHORT[short] = short, short +end +function R.status(value) + if value == false or value == "" or value == 0 then + return nil + end + return SHORT[value] or value +end +function R.nativeStatus(value, previous) + if value == nil or value == false then + return nil + end + local short = R.status(value) + assert(LONG[short], "unknown status") + if previous and LONG[SHORT[previous]] == previous then + return LONG[short] + end + return short +end +function R.stats(out, gen) + out.special = gen ~= 1 and out.specialAttack or out.special + return out +end +function R.shiny(raw, gen) + if raw.shiny ~= nil then + return raw.shiny == true + end + if gen ~= 3 then + return Stats.isShiny(raw.dvs) + end + if raw.isShiny ~= nil then + return raw.isShiny == true + end + local p = (tonumber(raw.personality) or 0) % 4294967296 + local id = (tonumber(raw.otId or raw.trainerId) or 0) % 4294967296 + local secret = tonumber(raw.otSecretId) or math.floor(id / 65536) + return bit.bxor(id % 65536, secret, math.floor(p / 65536), p % 65536) < 8 +end +function R.mon(out, data, gen) + if type(out) ~= "table" then + return out + end + out.status = R.status(out.status) + out.stats = R.stats(out.stats or {}, gen) + local raw = out.native or {} + local def = ((data or {}).pokemon or {})[raw.species or out.species] or {} + out.name = out.nickname and out.nickname ~= "" and out.nickname or out.name or def.name or out.species + out.dvs = gen ~= 3 and type(raw.dvs) == "table" and raw.dvs or nil -- raw is already a detached snapshot + out.shiny = R.shiny(raw, gen) + return out +end +return R diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua index 6d702f4b1..4ce1679c5 100644 --- a/src/mods/CallbackRecords.lua +++ b/src/mods/CallbackRecords.lua @@ -1,3 +1,4 @@ +local Api3Records = require("src.mods.Api3Records") -- A call-local transaction. Only known record roles cross this boundary; -- opaque engine handles (Game, battle, save, map, RNG, UI callbacks) stay native. local Ids = require("src.mods.UnifiedIds") @@ -139,8 +140,22 @@ local function array(t, label, max) assert(count == n, label .. " must be dense") end -function R.new(loader, name, generation) +function R.new(loader, name, generation, api3) local ids = Ids.new(loader) + local portable = api3 and Api3Records or nil + local function readStatus(value) + if portable then + return portable.status(value) + end + return status[value] or value + end + local function writeStatus(value, old) + if portable then + return portable.nativeStatus(value, old) + end + assert(value == nil or value == false or short[value], "unknown status") + return short[value] + end local byRaw, byView, entries = {}, {}, {} local view, restore, commit, refresh local open = true @@ -193,6 +208,9 @@ function R.new(loader, name, generation) end out.specialAttack = out.specialAttack or out.special out.specialDefense = out.specialDefense or out.special + if portable then + portable.stats(out, generation) + end return out end local function fields(raw, kind) @@ -201,12 +219,14 @@ function R.new(loader, name, generation) species = read("pokemon", raw.species), item = read("items", raw.item), nickname = raw.nickname, - name = raw.name, + name = portable and raw.nickname and raw.nickname ~= "" and raw.nickname or raw.name, level = raw.level, hp = raw.hp, maxHp = raw.stats and raw.stats.hp, - status = status[raw.status] or raw.status, + status = readStatus(raw.status), isEgg = raw.isEgg == true, + dvs = portable and generation ~= 3 and type(raw.dvs) == "table" and copy(raw.dvs) or nil, + shiny = portable and portable.shiny(raw, generation), stats = statView(raw.stats), moves = moveViews(raw.moves), native = copy(raw), @@ -284,7 +304,7 @@ function R.new(loader, name, generation) role = nil end if k == "status" then - out[k] = status[v] or v + out[k] = readStatus(v) elseif k == "move" and type(v) ~= "table" then out[k] = read("moves", v) elseif kind == "action" and k == "id" and not raw.special then @@ -348,6 +368,43 @@ function R.new(loader, name, generation) for k in pairs(public) do assert(stats[k], "unknown stat " .. tostring(k)) end + if portable then + local common = public.special ~= baseline.special and public.special or nil + if generation == 1 then + for _, k in ipairs({ "specialAttack", "specialDefense" }) do + if public[k] ~= baseline[k] then + assert(common == nil or common == public[k], "conflicting special stats") + common = public[k] + end + end + elseif common ~= nil then + for _, k in ipairs({ "specialAttack", "specialDefense" }) do + assert(public[k] == baseline[k] or public[k] == common, "conflicting special stats") + end + end + for k, n in pairs(stats) do + if k ~= "special" or generation == 1 then + local value = public[k] + if common ~= nil and (k == "special" or k == "specialAttack" or k == "specialDefense") then + value = common + end + if value ~= baseline[k] then + value = integer(value, 1, 65535, "stats." .. k) + if generation == 1 and (k == "specialAttack" or k == "specialDefense") then + n = "special" + end + local target = n + ops[#ops + 1] = function() + old[target] = value + if generation > 1 and target == "spatk" and old.special ~= nil then + old.special = value + end + end + end + end + end + return + end local specialValue for _, k in ipairs({ "special", "specialAttack", "specialDefense" }) do if public[k] ~= baseline[k] and (generation == 1 or k ~= "specialDefense") then @@ -442,10 +499,16 @@ function R.new(loader, name, generation) end elseif kind == "mon" then for k in pairs(value) do - assert(monFields[k], "unknown Pokemon field " .. tostring(k)) + assert( + monFields[k] or (portable and (k == "dvs" or k == "shiny")), + "unknown Pokemon field " .. tostring(k) + ) end assert(equal(value.native, before.native), "native snapshot is read-only") assert(value.name == before.name, "name is read-only; set nickname") + if portable then + assert(equal(value.dvs, before.dvs) and value.shiny == before.shiny, "DVs and shiny are read-only") + end for _, key in ipairs({ "species", "item", "nickname", "level", "hp", "maxHp", "status", "isEgg" }) do local v = value[key] if not equal(v, before[key]) then @@ -459,8 +522,7 @@ function R.new(loader, name, generation) elseif key == "maxHp" then v = integer(v, 1, 65535, key) elseif key == "status" then - assert(v == nil or v == false or short[v], "unknown status") - v = short[v] + v = writeStatus(v, raw.status) elseif key == "nickname" then assert(v == nil or type(v) == "string", "nickname must be a string") elseif key == "isEgg" then @@ -590,8 +652,7 @@ function R.new(loader, name, generation) elseif kind == "action" and k == "id" and not value.special and v ~= before[k] then stage(raw, k, native("moves", v, old)) elseif k == "status" and v ~= before[k] then - assert(v == nil or v == false or short[v], "unknown status") - stage(raw, k, short[v]) + stage(raw, k, writeStatus(v, old)) elseif idRoles[k] and v ~= before[k] then stage(raw, k, native(idRoles[k], v, old)) elseif k == "applyShare" and name == "battle.exp_award" then diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 7d027fb2f..ba46218d0 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -838,7 +838,7 @@ function Loader:_api(mod) -- new game and a new load. local hostGen, hostId, hostInfo = 1, nil, nil do - local okV, GameVersion = pcall(require, "src.core.GameVersion") + local okV, GameVersion = pcall(engineRequire, "src.core.GameVersion") if okV and GameVersion then local okG, g = pcall(GameVersion.generation) if okG and tonumber(g) then hostGen = tonumber(g) end @@ -852,7 +852,7 @@ function Loader:_api(mod) local battle = BattleSnapshots.new(function() return loader:_game(true) end, loader) local engineVersion do - local okE, V = pcall(require, "src.core.Version") + local okE, V = pcall(engineRequire, "src.core.Version") engineVersion = okE and V and V.engine or nil end local api = { @@ -894,7 +894,7 @@ function Loader:_api(mod) label = hostInfo and hostInfo.label or hostId, engine = engineVersion, modApi = (function() - local okE, V = pcall(require, "src.core.Version") + local okE, V = pcall(engineRequire, "src.core.Version") return okE and V and V.modApi or nil end)(), developer = loader.dev == true, @@ -1199,6 +1199,9 @@ function Loader:_api(mod) return supports(self, surface, name) end end + if (mod.manifest.api or 1) >= 3 then + engineRequire("src.mods.Api3").install(api, loader, readContext, overviewContext, engineRequire) + end return api end diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 3a3f6ff4a..3267b2c54 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -223,6 +223,12 @@ function Manifest.validate(raw, path) assert(api <= Version.modApi, ("requires mod API %d; this engine provides %d") :format(api, Version.modApi)) local strict = api >= 2 + -- API 3 is one portable contract, not three independent API-2 opt-ins. + for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + assert(api < 3 or raw[field] == nil or raw[field] == "unified", + "API 3 requires unified " .. field) + end + local defaultFormat = api >= 3 and "unified" or "native" assert(raw.content_format == nil or raw.content_format == "native" or raw.content_format == "unified", "content_format must be native or unified") assert(raw.content_format ~= "unified" or api >= 2, @@ -244,7 +250,7 @@ function Manifest.validate(raw, path) end end - local scriptFormat = raw.script_format == nil and "native" or raw.script_format + local scriptFormat = raw.script_format == nil and defaultFormat or raw.script_format assert(scriptFormat == "native" or scriptFormat == "unified", "script_format must be native or unified") assert(scriptFormat ~= "unified" or api >= 2, "unified scripts require mod API 2") @@ -317,7 +323,7 @@ function Manifest.validate(raw, path) version = raw.version, entry = raw.entry, api = api, - content_format = raw.content_format or "native", + content_format = raw.content_format or defaultFormat, priority = tonumber(raw.priority) or 0, dependencies = array(raw.dependencies), optional_dependencies = array(raw.optional_dependencies), @@ -329,7 +335,7 @@ function Manifest.validate(raw, path) category = raw.category or "OTHER", game_version = raw.game_version, description = raw.description or "", - callback_format = raw.callback_format or "native", + callback_format = raw.callback_format or defaultFormat, github = github, support = support, updateCheck = updateCheck, diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index ce1659a02..5e6a6195d 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -1,3 +1,4 @@ +local Api3Catalog = require("src.mods.Api3Catalog") local Records = require("src.mods.CallbackRecords") local Ids = require("src.mods.UnifiedIds") local C = {} @@ -83,12 +84,19 @@ function C.install(loader, api, owner, generation) -- Capture pristine alias identities before Loader merges them into separate -- records. Each call still builds a fresh index, including later mods' IDs. local ids = Ids.new(loader) + local api3 = (api.manifest.api or 1) >= 3 + local extra = api3 and Api3Catalog for _, kind in ipairs({ "pokemon", "moves", "items" }) do ids:entries(kind) end local function check(catalog, name) assert( - type(name) == "string" and (catalog[name] or (catalog == C.events and custom(name))), + type(name) == "string" + and ( + catalog[name] + or (extra and (catalog == C.events and extra.events[name] or catalog == C.hooks and extra.hooks[name])) + or (catalog == C.events and custom(name)) + ), "unknown unified callback: " .. tostring(name) ) end @@ -98,7 +106,7 @@ function C.install(loader, api, owner, generation) local wrapped = callback if not custom(name) and not nativeEvents[name] then wrapped = function(payload) - local boundary = Records.new(loader, name, generation) + local boundary = Records.new(loader, name, generation, api3) local public = boundary.view(payload, name == "pokemon.before_give" and "gift" or "context") local values = pack(callback(public)) if name == "pokemon.before_give" then @@ -130,7 +138,7 @@ function C.install(loader, api, owner, generation) check(C.hooks, name) assert(type(callback) == "function", "hook callback must be a function") return loader.hooks:wrap(name, function(nextFn, ...) - local boundary = Records.new(loader, name, generation) + local boundary = Records.new(loader, name, generation, api3) local roles = positions[name] or {} local function translate(args) local out = { n = args.n } diff --git a/tests/api3_contract/mods/portable/main.lua b/tests/api3_contract/mods/portable/main.lua new file mode 100644 index 000000000..a428b942f --- /dev/null +++ b/tests/api3_contract/mods/portable/main.lua @@ -0,0 +1,110 @@ +-- This exact entry and manifest run on both engines. No engine imports, +-- format switches or engine-specific branches belong in this fixture. +local mod = ... +local world, battle = mod.world, mod.battle +assert(type(world) == "table" and type(battle) == "table", "early handles") +local function refused(value, reason) + assert(value == nil and type(reason) == "string" and #reason > 0, tostring(reason)) +end +refused(world:party()) +refused(battle:snapshot()) +local no, why = mod:supports("world", "not_a_method") +assert(no == false and type(why) == "string") +assert(mod:supports("content", "pokemon")) +assert(mod:supports("hooks", "evolution.check")) +assert(mod:supports("events", "pokemon.received")) +assert(mod:supports("world", "party")) +-- Known API-3 names are registrable even when this host cannot execute them. +mod.hooks:wrap("render.output", function(next, ctx) + return next(ctx) +end) +mod.events:on("checkpoint.restored", function() end) +local c = mod.content +assert(c.pokemon:has("MEW")) +assert(c.maps:supports() and c.maps.available) +assert(c.maps:has("ABSENT_MAP") == false) +local map = assert(c.maps:get("PROBE")) +map.width = 99 +assert(c.maps:get("PROBE").width == 2) +for id, row in c.maps:each() do + if id == "api3Probe" then + row.value = 88 + end +end +assert(c.maps:get("PROBE").width == 2) +local before = assert(c.pokemon:get("MEW")) +assert(before.id == "MEW" and before.baseStats.hp == 100) +assert(before.level1Moves[1] == "POUND") +assert(before.baseStats.special == 100) +assert(c.moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT") +before.baseStats.hp = 222 +assert(c.pokemon:get("MEW").baseStats.hp == 100, "detached read") +assert(c.pokemon:patch("MEW", { baseStats = { hp = 101 } })) +assert(c.moves:patch("POUND", { power = 45 })) +assert(c.items:patch("POTION", { price = 123 })) +local found = false +for id, row in c.pokemon:each() do + if id == "MEW" then + found = true + assert(row.baseStats.hp == 101) + end +end +assert(found) +local report = { species = "MEW", hp = 101, move = "POUND", power = 45, price = 123 } +local function monCheck(mon) + assert(mon.species == "MEW" and mon.nickname == "Scout" and mon.name == "Scout") + assert(mon.status == "PSN", "short status") + assert(type(mon.shiny) == "boolean", "shiny presentation") + assert(mon.stats.special == 24 and mon.stats.hp == 40, "common stats") + assert(mon.moves[1].id == "POUND" and mon.moves[1].pp == 30 and mon.moves[1].maxPp == 35) +end +mod.events:on("pokemon.received", function(payload) + monCheck(payload.mon) + payload.mon.hp = 1 + report.event = true +end) +mod.hooks:wrap("evolution.check", function(next, game, mon, evolution, trigger) + monCheck(mon) + mon.hp, mon.status = 27, "BRN" + mon.stats.special = 31 + report.hook = true + report.special = 31 + return next(game, mon, evolution, trigger) +end) +mod.events:on("mod.portable.probe", function(payload) + report.custom = payload.value +end) +mod.events:emit("mod.portable.probe", { value = 7 }) +mod.exports.exercise = function() + local first = assert(world:party()) + monCheck(first[1]) + first[1].hp = 1 + assert(world:party()[1].hp == 30, "detached party") + report.party = true + report.status, report.shiny = first[1].status, first[1].shiny + assert(world:getFlag("mod:contract.flag") == false) + assert(world:setFlag("mod:contract.flag", true)) + assert(world:getFlag("mod:contract.flag") == true) + assert(world:setFlag("mod:contract.flag", false)) + assert(world:getFlag("mod:contract.flag") == false) + report.flags = true +end +mod.exports.snapshot = function() + local first = assert(battle:snapshot()) + assert(first.kind == "wild" and first.prompt == "menu") + monCheck(first.player) + assert(first.player.hp == 30 and first.player.maxHp == 40) + assert(type(first.revision) == "number" and first.revision > 0) + first.player.hp = 1 + assert(battle:snapshot().player.hp == 30, "detached battle snapshot") + refused(battle:submit({ id = 1, revision = first.revision, kind = "not_an_intent" })) + report.battle = + { kind = first.kind, prompt = first.prompt, species = first.player.species, status = first.player.status } +end +mod.exports.late = function() + local ok, value, reason = pcall(function() + return c.moves:patch("POUND", { power = 99 }) + end) + assert(not ok or (value == nil and type(reason) == "string"), "frozen content") +end +mod.exports.world, mod.exports.battle, mod.exports.report = world, battle, report diff --git a/tests/api3_contract/mods/portable/manifest.json b/tests/api3_contract/mods/portable/manifest.json new file mode 100644 index 000000000..d98d5f821 --- /dev/null +++ b/tests/api3_contract/mods/portable/manifest.json @@ -0,0 +1 @@ +{"id":"portable","name":"Portable API contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/rollback/main.lua b/tests/api3_contract/mods/rollback/main.lua new file mode 100644 index 000000000..daf6abe4a --- /dev/null +++ b/tests/api3_contract/mods/rollback/main.lua @@ -0,0 +1,6 @@ +local mod = ... +assert(mod.content.pokemon:patch("MEW", { baseStats = { hp = 199 } })) +mod.events:on("mod.portable.probe", function() + error("failed subscription leaked") +end) +error("intentional API 3 rollback") diff --git a/tests/api3_contract/mods/rollback/manifest.json b/tests/api3_contract/mods/rollback/manifest.json new file mode 100644 index 000000000..ab532b029 --- /dev/null +++ b/tests/api3_contract/mods/rollback/manifest.json @@ -0,0 +1 @@ +{"id":"rollback","name":"Rollback contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua","dependencies":["portable"]} diff --git a/tests/api3_contract/run.lua b/tests/api3_contract/run.lua new file mode 100644 index 000000000..0024a01d9 --- /dev/null +++ b/tests/api3_contract/run.lua @@ -0,0 +1,256 @@ +-- Shared harness; only fixture construction knows the native engine layout. +local M = {} +local function read(path) + local f = assert(io.open(path, "rb")) + local body = f:read("*a") + f:close() + return body +end +local function dataFor(backend, generation) + if backend == "gen1" and generation == 3 then + local fixture = require("tests.modkit.sdk").gen3Data() + fixture.maps.PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } } + return fixture + end + local stats = { hp = 100, attack = 100, defense = 100, speed = 100, special = 100 } + local mon = { + id = "MEW", + name = "MEW", + dex = 151, + types = { "PSYCHIC" }, + baseStats = stats, + catchRate = 45, + baseExp = 64, + growthRate = "MEDIUM_FAST", + spriteFront = "fixture.png", + spriteBack = "fixture.png", + frontSize = 5, + level1Moves = { "POUND" }, + learnset = {}, + evolutions = {}, + } + local effect = "NO_ADDITIONAL_EFFECT" + if generation > 1 then + stats.spatk, stats.spdef = 100, 100 + end + if backend == "gen1" and generation == 2 then + stats.special, stats.spatk, stats.spdef = nil, nil, nil + stats.specialAttack, stats.specialDefense = 100, 100 + mon.level1Moves, mon.learnset, mon.frontSize = nil, nil, nil + mon.levelMoves, mon.picSize = { { level = 1, move = "POUND" } }, 5 + effect = "EFFECT_NORMAL_HIT" + end + local pokemon = { MEW = mon } + if backend == "gen1" and generation == 2 then + pokemon.growthRates = + { MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 } } + end + return { + pokemon = pokemon, + moves = { + POUND = { + id = "POUND", + name = "POUND", + type = "NORMAL", + power = 40, + accuracy = 100, + pp = 35, + effect = effect, + }, + }, + items = { POTION = { id = "POTION", name = "POTION", price = 300, tossable = true } }, + maps = { PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } } }, + trainers = {}, + encounters = {}, + audio = {}, + type_chart = { types = { NORMAL = {}, PSYCHIC = {} }, matchups = {} }, + gen2Maps = backend == "gen1" and generation == 2 and { + PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } }, + } or nil, + } +end +local function nativeMon(backend, gen) + local stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24 } + local mon = { + species = "MEW", + nickname = "Scout", + name = "Native name", + level = 20, + hp = 30, + status = "PSN", + stats = stats, + moves = { { id = "POUND", pp = 30, ppUps = 0 } }, + } + if gen > 1 then + stats.spatk, stats.spdef = 24, 25 + end + if backend == "gen1" and gen > 1 then + stats.special, stats.spatk, stats.spdef = nil, nil, nil + stats.specialAttack, stats.specialDefense = 24, 25 + mon.status = gen == 2 and "poison" or 8 + if gen == 3 then + mon.species, mon.moves[1].id = 151, 1 + end + end + return mon +end +function M.run(root, backend, version, failing, scaffold) + local Loader = require("src.mods.Loader") + local Runtime = require("src.mods.Runtime") + local Version = require("src.core.GameVersion") + local Json = require("src.link.Json") + Version.set(version) + local generation = Version.generation(version) + local data = dataFor(backend, generation) + local files = {} + for _, id in ipairs(failing and { "portable", "rollback" } or { "portable" }) do + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/" .. id .. "/" .. name] = read(root .. "/mods/" .. id .. "/" .. name) + end + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(assert(files[p]), "@" .. p) + end, + getInfo = function(p) + if files[p] then + return { type = "file", size = #files[p] } + end + for key in pairs(files) do + if key:sub(1, #p + 1) == p .. "/" then + return { type = "directory" } + end + end + end, + getDirectoryItems = function(p) + if p == "mods" then + return failing and { "portable", "rollback" } or { "portable" } + end + return {} + end, + } + local loader = Loader.new({ fs = fs, dev = false, generation = generation, version = version }) + local game + loader._game = function(_, loadedOnly) + if backend == "gen2" then + assert(loadedOnly, "API 3 must not bootstrap Game") + end + return game + end + local ok = loader:load(data) + if scaffold then + assert(ok, table.concat(loader.errors, "; ")) + assert(loader.mods.portable.manifest.api == 3, "generated manifest selects API 3") + local power = backend == "gen1" and generation == 3 and data.gen3Moves._rom[1].power + or data.moves.POUND.power + assert(power == 45, "generated entry changed the native move") + local report = { scaffold = true, power = power } + print("API3_CONTRACT " .. Json.encode(report)) + return report + end + local errorCount = #loader.errors + local exported = assert(loader.exports.portable, table.concat(loader.errors, "; ")) + assert(exported.report, table.concat(loader.errors, "; ")) + if failing then + assert(not ok and table.concat(loader.errors, "; "):find("intentional API 3 rollback", 1, true)) + else + assert(ok, table.concat(loader.errors, "; ")) + end + local nativeHp = backend == "gen1" and generation == 3 and data.gen3Pokemon._stats[151].hp + or data.pokemon.MEW.baseStats.hp + assert(nativeHp == 101, "rollback/native merge") + assert( + (backend == "gen1" and generation == 2 and data.gen2Maps or data.maps).PROBE.width == 2, + "passthrough reads remain detached" + ) + local raw = nativeMon(backend, generation) + local save = { party = { raw }, flags = {}, modData = {}, money = 0 } + game = { data = data, save = save, stack = { states = {} } } + if backend == "gen1" and generation == 3 then + game.session = save + end + exported.exercise() + Runtime.emit("pokemon.received", { mon = raw }) + assert(raw.hp == 30, "event read did not mutate native Pokemon") + loader.hooks:call("evolution.check", function(_, mon) + assert(mon == raw and mon.hp == 27, "native hook forwarding") + assert( + mon.status + == ( + backend == "gen1" and generation == 3 and 16 + or backend == "gen1" and generation == 2 and "burn" + or "BRN" + ), + "native status writeback" + ) + local attack = mon.stats.spatk or mon.stats.specialAttack or mon.stats.special + local defense = mon.stats.spdef or mon.stats.specialDefense or mon.stats.special + assert(attack == 31 and defense == 31, "common Special writes both later stats") + return "UNCHANGED" + end, game, raw, {}, {}) + assert(raw.hp == 27) + exported.late() + Runtime.emit("mod.portable.probe", { value = 8 }) + assert(exported.report.custom == 8, "custom callback reached live owner") + assert(#loader.errors == errorCount, "no callback errors or failed-owner subscription leaks") + if generation == 1 then + require("src.battle.TypeChart").load(data) + local player, enemy = nativeMon(backend, generation), nativeMon(backend, generation) + local function battler(mon) + return { + mon = mon, + species = mon.species, + name = mon.nickname, + curStats = mon.stats, + curTypes = { "PSYCHIC" }, + curMoves = mon.moves, + stages = {}, + } + end + local battle = { + isBattleState = true, + game = game, + data = data, + kind = "wild", + phase = "menu", + queue = {}, + player = battler(player), + enemy = battler(enemy), + enemyParty = { enemy }, + ruleset = {}, + turnCount = 0, + sides = { { battlers = {} }, { battlers = {} } }, + } + function battle:battleKind() + return "wild" + end + function battle:visibleText() + return {} + end + function battle:effectRecord() + return { accuracyChecked = true } + end + game.save.party, game.save.inventory = { player }, {} + game.stack.states = { battle } + exported.snapshot() + assert(player.hp == 30, "invalid intent never dispatched") + end + -- The entry-time world handle must follow a later Game instead of retaining the old save. + local replacement = nativeMon(backend, generation) + replacement.hp = 19 + local nextSave = { party = { replacement }, modData = {}, flags = {} } + game = { data = data, save = nextSave, stack = { states = {} } } + if backend == "gen1" and generation == 3 then + game.session = nextSave + end + assert(exported.world:party()[1].hp == 19, "captured facade follows current game") + assert(exported.battle:snapshot() == nil, "no battle") + local result = exported.report + assert(result.event and result.hook and result.party and result.custom == 8) + print("API3_CONTRACT " .. Json.encode(result)) + return result +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua new file mode 100644 index 000000000..b7dcb960d --- /dev/null +++ b/tests/api3_contract/worker.lua @@ -0,0 +1,10 @@ +-- Separate process per engine/version: modules and runtime buses cannot leak. +package.path = "./?.lua;./?/init.lua;" .. package.path +love = nil +local root, backend, version, mode = arg[1], arg[2], arg[3], arg[4] +assert( + root and backend and version and (mode == "normal" or mode == "rollback" or mode == "scaffold"), + "invalid worker arguments" +) +local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") +dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua new file mode 100644 index 000000000..a34fe4ae9 --- /dev/null +++ b/tests/engine/api3_compat.lua @@ -0,0 +1,226 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Manifest = require("src.mods.Manifest") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +local function manifest(api, extra) + local raw = { id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua", api = api } + for k, v in pairs(extra or {}) do + raw[k] = v + end + return raw +end +for _, api in ipairs({ 1, 2, 3 }) do + local m = Manifest.validate(manifest(api)) + local format = api == 3 and "unified" or "native" + for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + T.eq(m[field], format, "API " .. api .. " default " .. field) + end +end +T.eq(Manifest.validate(manifest()).api, 1, "missing API remains legacy") +for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + T.raises(function() + Manifest.validate(manifest(3, { [field] = "native" })) + end, "API 3 requires unified", "API 3 refuses contradictory " .. field) + T.eq( + Manifest.validate(manifest(2, { [field] = "unified" }))[field], + "unified", + "API 2 keeps explicit opt-in" + ) + T.raises(function() + Manifest.validate(manifest(1, { [field] = "unified" })) + end, "mod API 2", "API 1 opt-in remains rejected") +end +T.raises(function() + Manifest.validate(manifest(4)) +end, "provides 3", "future API not silently accepted") +T.raises(function() + Manifest.validate(manifest(3.5)) +end, "positive integer", "fractional API rejected") +local function boot(api, extra, second, version) + Version.set(version or "red") + local rows = { probe = manifest(api, extra) } + if second then + rows.other = manifest(api, extra) + rows.other.id = "other" + end + local files = {} + for id, raw in pairs(rows) do + files["mods/" .. id .. "/manifest.json"] = Json.encode(raw) + files["mods/" .. id .. "/main.lua"] = "local mod = ...; mod.exports.api = mod" + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getInfo = function(p) + if files[p] then + return { type = "file" } + end + if p == "mods" or rows[p:match("^mods/(.*)$")] then + return { type = "directory" } + end + end, + getDirectoryItems = function(p) + return p == "mods" and (second and { "probe", "other" } or { "probe" }) or {} + end, + } + local loader = Loader.new({ fs = fs, dev = true }) + local game, probes = nil, 0 + loader._game = function(_, loadedOnly) + probes = probes + 1 + if api == 3 then + assert(loadedOnly, "API 3 requested Game bootstrap") + end + return game + end + T.check(loader:load({ pokemon = {}, moves = {}, items = {} }), "real loader accepts API " .. api) + local mod = assert(loader.apis.probe) + return mod, loader, function(value) + game = value + end, function() + return probes + end +end +-- Real legacy loaders keep native defaults, API-2 status vocabulary and callback catalog. +for _, api in ipairs({ 1, 2 }) do + local m = boot(api) + T.eq(m.contentFormat, "native", "legacy content native") + T.eq(m.callbackFormat, "native", "legacy callbacks native") + T.eq(m.scriptFormat, "native", "legacy scripts native") +end +do + local m, loader, attach = + boot(2, { content_format = "unified", callback_format = "unified", script_format = "unified" }) + local mon = { + species = "MEW", + nickname = "Scout", + hp = 10, + status = "PSN", + stats = { hp = 20, special = 9 }, + moves = {}, + } + attach({ + save = { party = { mon } }, + data = { pokemon = {}, moves = {}, items = {} }, + stack = { + states = {}, + }, + }) + T.eq(m.world:party()[1].status, "poison", "API-2 unified party status unchanged") + local seen + m.events:on("pokemon.received", function(p) + seen = p.mon.status + end) + Runtime.emit("pokemon.received", { mon = mon }) + T.eq(seen, "poison", "API-2 unified event status unchanged") + T.raises(function() + m.events:on("checkpoint.restored", function() end) + end, "unknown unified callback", "API-2 catalog unchanged") +end +-- Availability must be honest, and a probe cannot construct Game or attach a world. +do + local m, loader, attach, probes = boot(3, nil, true) + T.eq(probes(), 0, "entry does not resolve Game") + local world, battle = m.world, m.battle + T.eq(type(world), "table", "world facade exists before Game") + T.eq(type(world.party), "function", "early world method callable") + T.eq(type(battle.snapshot), "function", "early battle method callable") + for _, name in ipairs({ + "party", + "current", + "activeBlockAt", + "liveMaps", + "queueScript", + "getFlag", + "setFlag", + }) do + T.eq(m:supports("world", name), true, "implemented world method " .. name) + end + for _, name in ipairs({ + "warpTo", + "toggleObject", + "replaceBlock", + "spawnNpc", + "removeNpc", + "npc", + "startWildBattle", + "invalidateMap", + }) do + local available, why = m:supports("world", name) + T.eq(available, false, "no unverified native promise for " .. name) + T.check(type(why) == "string", "unavailable reason for " .. name) + local ok, reason = world[name](world, "bad") + T.eq(ok, nil, "unsupported method does not dispatch " .. name) + T.check(reason:find("unsupported:", 1, true), "explicit refusal for " .. name) + end + T.eq(probes(), 0, "capability and unsupported calls do not resolve Game") + T.eq(world:party(), nil, "no-game party refuses safely") + T.eq(battle:snapshot(), nil, "no-game battle refuses safely") + T.eq(m.datasets:open("gold"), nil, "unavailable dataset API callable") + T.eq(m:supports("hooks", "render.output"), false, "recognized output hook is not falsely available") + T.check( + pcall(function() + m.hooks:wrap("render.output", function(next, ctx) + return next(ctx) + end) + end), + "known absent hook can be registered" + ) + T.check( + pcall(function() + m.events:on("checkpoint.restored", function() end) + end), + "known absent event can be registered" + ) + T.raises(function() + m.events:on("not.catalogued", function() end) + end, "unknown unified callback", "unknown API-3 name rejected") + local save = { party = {}, flags = {}, modData = {} } + attach({ save = save, data = {}, stack = { states = {} } }) + local flags = {} + m.events:on("flag.changed", function(p) + flags[#flags + 1] = { p.name, p.value } + end) + T.eq(world:getFlag("mod:quest.finished"), false, "flag read defaults false") + T.eq(next(save.modData), nil, "flag read allocates no bucket") + T.check(world:setFlag("mod:quest.finished", true), "private flag set") + T.check(world:setFlag("mod:quest.finished", true), "setting same flag accepted") + T.eq(#flags, 1, "unchanged flag emits no duplicate event") + T.eq(loader.apis.other.world:getFlag("mod:quest.finished"), false, "other mod cannot see flag") + T.check(world:setFlag("mod:quest.finished", false), "private flag cleared") + T.eq(save.modData.probe.worldFlags["quest.finished"], nil, "clear removes saved boolean") + T.same( + flags, + { { "mod.probe.quest.finished", true }, { "mod.probe.quest.finished", false } }, + "portable flag notifications" + ) + T.eq(next(save.flags), nil, "private flags never touch native flags") + T.eq(world:setFlag("mod:bad", "yes"), nil, "non-boolean flag rejected") + T.eq(world:setFlag("NATIVE_FLAG", true), nil, "unverified native flag mapping refused") + T.eq(world:setFlag("mod:", true), nil, "empty private flag rejected") + save.modData.probe.worldFlags = false + T.eq(world:setFlag("mod:test", true), nil, "malformed store rejected") + T.eq(save.modData.probe.worldFlags, false, "malformed store never overwritten") + attach({ save = { party = {} }, data = {}, stack = { states = {} } }) + T.eq(world:getFlag("mod:quest.finished"), false, "same handle follows replacement save") + T.eq(m.world, world, "world handle identity stable") + T.eq(m.battle, battle, "battle handle identity stable") +end +for _, version in ipairs({ "platinum", "black" }) do + local m = boot(3, nil, false, version) + T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) + local value, why = m.world:party() + T.eq(value, nil, "unsupported generation call refuses") + T.check(why:find("unsupported:", 1, true), "unsupported generation is explicit") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5] +T.finish("API 3 entry compatibility") diff --git a/tests/engine/api3_entry.lua b/tests/engine/api3_entry.lua new file mode 100644 index 000000000..7eb1a2712 --- /dev/null +++ b/tests/engine/api3_entry.lua @@ -0,0 +1,16 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.run") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + for _, failing in ipairs({ false, true }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version, failing) + T.check(ok, version .. (failing and " rollback: " or ": ") .. tostring(why)) + end +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5] +T.finish("API 3 cross-engine contracts") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py new file mode 100644 index 000000000..48ba0eb9b --- /dev/null +++ b/tools/api3-contract-check/check.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Run the identical API-3 mod files on Gen2Recomped and pinned Gen1Recomp.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +GEN1_REF = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +VERSIONS = ("red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald") + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--gen1", required=True, type=Path, help="clean checkout at the pinned Gen1 commit") + parser.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + gen1, gen2 = args.gen1.resolve(), args.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1_REF: + parser.error(f"Gen1 must be pinned to {GEN1_REF}") + if git(gen1, "status", "--porcelain", "--untracked-files=normal"): + parser.error("Gen1 reference has tracked modifications; use an isolated checkout") + fixtures = gen2 / "tests" / "api3_contract" + manifest = {} + for path in sorted((fixtures / "mods").rglob("*")): + if path.is_file(): + manifest[str(path.relative_to(fixtures))] = hashlib.sha256(path.read_bytes()).hexdigest() + results = [] + scaffold_temp = tempfile.TemporaryDirectory(prefix="api3-scaffold-") + scaffold_root = Path(scaffold_temp.name) + for name, options in (("portable", ["--api", "3"]), ("legacy", [])): + subprocess.run([sys.executable, str(gen2 / "tools/modkit.py"), "scaffold", name, + "--dest", str(scaffold_root / "mods"), "--quiet", *options], + cwd=gen2, check=True, capture_output=True, text=True, timeout=60) + generated = json.loads((scaffold_root / "mods/portable/manifest.json").read_text()) + assert generated["api"] == 3 and "game_version" not in generated + assert generated["games"] == ["gen1", "gen2", "gen3"] + legacy = json.loads((scaffold_root / "mods/legacy/manifest.json").read_text()) + assert legacy["api"] == 2 and legacy["game_version"].startswith(">=") + generated_hashes = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in (scaffold_root / "mods/portable").iterdir() + if path.is_file()} + for version in VERSIONS: + for mode in ("normal", "rollback", "scaffold"): + values = {} + for backend, engine in (("gen1", gen1), ("gen2", gen2)): + source = scaffold_root if mode == "scaffold" else fixtures + command = [args.lua, str(fixtures / "worker.lua"), str(source), backend, version, mode] + run = subprocess.run(command, cwd=engine, text=True, capture_output=True, timeout=60) + reports = [line.removeprefix("API3_CONTRACT ") for line in run.stdout.splitlines() + if line.startswith("API3_CONTRACT ")] + if run.returncode or len(reports) != 1: + raise RuntimeError(f"{backend}/{version}/{mode} failed:\n{run.stdout}{run.stderr}") + values[backend] = json.loads(reports[0]) + if values["gen1"] != values["gen2"]: + raise RuntimeError(f"contract mismatch for {version}/{mode}: {values}") + results.append({"version": version, "mode": mode, "result": values["gen1"]}) + print(f"PASS {version}/{mode}: identical mod and public result") + report = {"gen1_ref": GEN1_REF, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "gen2_tracked_changes": bool(git(gen2, "status", "--porcelain", "--untracked-files=no")), + "fixture_sha256": manifest, "scaffold_sha256": generated_hashes, "comparisons": results} + if args.report: + args.report.write_text(json.dumps(report, indent=2) + "\n") + scaffold_temp.cleanup() + print(f"API 3: {len(results)} cross-engine comparisons, {len(results) * 2} isolated loader runs passed") + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, subprocess.SubprocessError, OSError, ValueError, AssertionError) as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/tools/modkit.py b/tools/modkit.py index fb0b91499..91c23ffa4 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -323,10 +323,9 @@ def check_requires(repo, mod_dir, manifest): "id": "{{id}}", "name": "{{name}}", "version": "0.1.0", - "api": 2, - "entry": "main.lua", - "profile": "{{profile}}", - "game_version": ">={{game_version}} <{{next_major}}.0.0", + "api": {{api}}, + "entry": "main.lua",{{games_line}} + "profile": "{{profile}}",{{engine_range_line}} "category": "GAMEPLAY", "priority": 100, "dependencies": [], @@ -378,7 +377,7 @@ def check_github_field(manifest): "MK001", "error", str(err), "manifest.json")) return findings, notes -MAIN_CONTENT = """-- {{id}}: a content-profile mod (api 2). +MAIN_CONTENT = """-- {{id}}: a content-profile mod (api {{api}}). -- The 10-minute loop: edit, save, F5 in a POKEPORT_DEV=1 game, repeat. return function(mod) -- patch, not override: every field you do not name keeps its base value @@ -391,7 +390,16 @@ def check_github_field(manifest): end """ -MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api 2). +MAIN_PORTABLE = """-- {{id}}: portable API {{api}} entry for Gen1Recomp and Gen2Recomped. +return function(mod) + if mod.content.moves:has("POUND") then + local ok, reason = mod.content.moves:patch("POUND", { power = 45 }) + if not ok then mod.log:warn("%s", reason) end + end +end +""" + +MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api {{api}}). return function(mod) mod.options:define({ { key = "difficulty", label = "DIFFICULTY", kind = "choice", @@ -411,7 +419,7 @@ def check_github_field(manifest): end """ -MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api 2). +MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api {{api}}). return function(mod) -- the new game itself: spawn, names, money (field.boot, D11) -- mod.content.field:patch("boot", { @@ -442,7 +450,7 @@ def check_github_field(manifest): README_TEMPLATE = """# {{name}} -A `{{profile}}` mod for the LOVE2D Pokemon Red engine (mod api 2). +A `{{profile}}` mod for {{target_engine}} (mod api {{api}}). ## Layout @@ -489,6 +497,10 @@ def cmd_scaffold(args, repo): "{{game_version}}": engine, "{{next_major}}": str(next_major), "{{extra}}": extra, "{{github_line}}": github_line, + "{{api}}": str(args.api), + "{{games_line}}": '\n "games": ["gen1", "gen2", "gen3"],' if args.api == 3 else "", + "{{engine_range_line}}": "" if args.api == 3 else f'\n "game_version": ">={engine} <{next_major}.0.0",', + "{{target_engine}}": "Gen1Recomp and Gen2Recomped" if args.api == 3 else "the LOVE2D Pokemon Red engine", "{{experimental}}": "true" if getattr(args, "experimental", False) else "false", } @@ -504,6 +516,8 @@ def emit(rel, template): main = {"content": MAIN_CONTENT, "overhaul": MAIN_OVERHAUL, "total_conversion": MAIN_TC}[profile] + if args.api == 3: + main = MAIN_PORTABLE layout_extra = "" if profile == "total_conversion": layout_extra = "- `transforms.lua` - asset transforms over the player's cache\n" @@ -519,7 +533,7 @@ def emit(rel, template): emit("transforms.lua", TRANSFORMS_TEMPLATE) if not args.quiet: - print(f"created {dest} ({profile} profile, api 2)") + print(f"created {dest} ({profile} profile, api {args.api})") print(f"next: python3 tools/modkit.py validate {args.id}") return 0 @@ -1964,7 +1978,7 @@ def main(argv): p.add_argument("id") p.add_argument("--profile", default="content", choices=["content", "overhaul", "total_conversion"]) - p.add_argument("--api", type=int, default=2) + p.add_argument("--api", type=int, choices=(1, 2, 3), default=2) p.add_argument("--github", default="", help="optional owner/repo (enables launcher auto-update)") p.add_argument("--experimental", action="store_true", From ab7d941dcdb58cb9d8a346ffb0aa6c6b8ef8a867 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:31:07 +0200 Subject: [PATCH 13/71] Add validated API 3 world warps and cross-engine contracts --- docs/api3-portability.md | 15 +- docs/api3-warp.md | 130 +++++++ docs/modding.md | 1 + src/mods/Api3.lua | 7 +- src/mods/Api3Warp.lua | 248 +++++++++++++ src/mods/Loader.lua | 4 + src/world/OverworldController.lua | 12 +- src/world/WorldActions.lua | 2 + tests/api3_contract/mods/warp/main.lua | 23 ++ tests/api3_contract/mods/warp/manifest.json | 1 + tests/api3_contract/run.lua | 1 + tests/api3_contract/warp.lua | 387 ++++++++++++++++++++ tests/api3_contract/worker.lua | 11 +- tests/engine/api3_compat.lua | 3 +- tests/engine/api3_warp.lua | 249 +++++++++++++ tools/api3-contract-check/check.py | 2 +- 16 files changed, 1084 insertions(+), 12 deletions(-) create mode 100644 docs/api3-warp.md create mode 100644 src/mods/Api3Warp.lua create mode 100644 tests/api3_contract/mods/warp/main.lua create mode 100644 tests/api3_contract/mods/warp/manifest.json create mode 100644 tests/api3_contract/warp.lua create mode 100644 tests/engine/api3_warp.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 52f592613..a484539fa 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -76,11 +76,12 @@ The common world calls currently enabled for generations 1–3 are: - `canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, `canFly`, `flyTo`; - `getFlag`/`setFlag` for private `mod:` boolean flags; -- `queueScript` for the existing bounded portable script language. +- `queueScript` for the existing bounded portable script language; +- [`warpTo`](api3-warp.md) for validated native map transitions. Check `mod:supports("world", name)` before relying on a call. The other Gen1 world method names remain callable but explicitly return -`nil, "unsupported: ..."`: warp/object/block mutations, runtime NPC operations, +`nil, "unsupported: ..."`: object/block mutations, runtime NPC operations, `startWildBattle` and `invalidateMap` have not yet been verified against the Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. @@ -126,15 +127,19 @@ process. The shared harness constructs native-shaped synthetic datasets; the mod fixtures contain no engine imports or engine-specific branches. The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds -eight comparisons to the sixteen normal/rollback cases: 24 comparisons and -48 isolated loader runs. It compares JSON projections of public results and +eight comparisons to the sixteen normal/rollback cases. Eight additional warp +cases use the same public mod on both engines and native-shaped timing seams: +32 comparisons and 64 isolated loader runs. It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge results, detached core and passthrough reads, callable early handles, later Game replacement, party fields, private flags, Gen1 battle snapshot/invalid-intent boundaries, callback payloads and native writeback, custom events, unsupported-name handling, frozen content and failed -owner cleanup. Separate Gen2 tests check API-1/2 behavior, contradictory +owner cleanup. Warp cases also compare validation errors, native dispatch +arguments, destination events, boundary coordinates, arrival options, callback +argument holes/replay, stale sessions/owners, synchronous completion and error +attribution. Separate Gen2 tests check API-1/2 behavior, contradictory manifest fields, honest availability and malformed flag stores. CI pins the same external commit and uploads the report. diff --git a/docs/api3-warp.md b/docs/api3-warp.md new file mode 100644 index 000000000..4b86542dc --- /dev/null +++ b/docs/api3-warp.md @@ -0,0 +1,130 @@ +# Portable API-3 world warps + +API-3 mods can use the same public call on Gen1Recomp and Gen2Recomped: + +```lua +local mod = ... +local world = mod.world -- Safe to retain at entry; does not create Game. + +-- Call later, from an idle overworld interaction/update. +local function goToDestination(mapId, cellX, cellY) + if not mod:supports("world", "warpTo") then return end + local accepted, reason = world:warpTo(mapId, cellX, cellY, "down", { + keepMusic = true, + onDone = function() + mod.log:info("Arrived") + end, + }) + if not accepted then mod.log:warn("Warp declined: %s", reason) end +end +``` + +`warpTo(mapId, x, y, facing, options)` returns `true` after native admission, +or `nil, reason`. Admission does not mean arrival. It uses the existing native +`OverworldState:startWarpTo` transition, map-entry events, spawn bookkeeping +and arrival-tile guard. No direct player/save relocation bypasses that path. +This API-3 adapter does not change API-1/2 facade defaults or validation. +Generations 1–3 advertise support; API 3 still refuses this world operation on +Gen4/5. Availability is a capability, not a promise that the current world is +idle or the named map exists. Reading the facade or querying support does not +bootstrap Game. + +## Admission and coordinates + +The current Game must have a save and a live, topmost overworld. Screens, +native/portable scripts, queued/parallel scripts, movement, transitions, +field/controller locks, link sessions and another pending API-3 warp decline. +Gen2Recomped reuses its existing world-action admission guard and checks its +additional native foreground owners. Idle-state details differ between the +engines; both keep their own native readiness checks. + +Map IDs are nonempty strings of at most 256 bytes, without control characters. +The map must exist in the current merged `game.data.maps`; custom merged maps +are supported. Gen3 also accepts unprefixed names when the current version's +prefixed map exists (`FR_`, `EM_`, `RU_`, `SA_`). An exact ID wins over an alias. +There is no cross-game map-name translation: the mod selects a real map in +the active dataset. + +`x` and `y` are finite, nonnegative whole numbers in zero-based **16-pixel +walk cells**. Gen1/2 dimensions are twice the map's block dimensions; Gen3 +uses the map's metatile dimensions directly. The final valid cell is +`widthInCells - 1, heightInCells - 1`. Invalid/missing map dimensions refuse +before dispatch. Coordinates are bounded, not clamped; terrain passability is +not part of this contract. + +Facing is `up`, `down`, `left` or `right`. Omitted facing follows Gen1's common +contract: `down` on generation 1, current player facing on generations 2/3, +with `down` as fallback. Explicit facing is recommended for portable scenes. + +Options must be a plain table. Only these fields are accepted: + +| Field | Meaning | +| --- | --- | +| `arrive` | `"fly"` or `"teleport"`, using native arrival presentation | +| `keepMusic` | Boolean; forwarded to native map loading | +| `onDone` | Function, called at most once after accepted native completion | +| `fade` | Boolean, generation 3 only; false uses the same native midpoint/completion without pushing a fade screen | + +Unknown options, invalid types and unsupported `fade` use decline the whole +request before native dispatch. Options are copied; later caller edits cannot +change an accepted request. Invalid admission does not set arrival effects, +change maps/coordinates/saves, raise map-entry events or call `onDone`. +Representative reasons include `no game`, `no overworld`, `no save`, +`world is busy`, `invalid map id`, `unknown map: `, `invalid coordinates`, +`coordinates out of bounds`, `invalid facing`, `invalid options`, +`invalid arrival`, `invalid callback`, `unsupported fade` and +`unknown warp option: `. These are admission failures, not exceptions. +Native failures after dispatch can already have native effects; there is no +transactional rollback of an entered transition. + +## Callback ownership + +`onDone` runs only for an accepted request whose Game, save, data, stack, +overworld, player, version, loader buses and mod lease are still current, at +the requested destination. It preserves the native callback arguments, +including nil holes. Synchronous native completion is held until the native +return confirms admission. A rejected/throwing native dispatch cannot invoke +the mod callback, and duplicate native completions are ignored. + +A replaced session/owner observed at input-step boundaries, another map entry +or leaving the destination retires the completion permanently. Returning to an +older session cannot revive a completion already retired. Loader rollback, +reload and teardown retire the facade and its pending callback; a retained +facade returns `nil, "warp facade is retired"`. Retirement does not pop screens +or undo work belonging to the native controller. + +The callback is attributed to its mod, errors enter the loader's runtime error +list once, and the previous runtime owner is restored. An error never retries +a warp or the callback. A completion may start a new request after the native +world becomes idle. The adapter adds no synthetic map-entry event; normal +native map lifecycle remains in control. + +The portable `queueScript` language is unchanged: adding this direct method +does not enable a `warp` script row. + +## Verification + +```sh +luajit tests/engine/api3_warp.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The existing Gen1 pin remains +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same manifest/entry is loaded by +both real loaders for Red, Blue, Yellow, Gold, Silver, Crystal, FireRed and +Emerald. The shared driver builds each engine's native-shaped Game/world data +and replaces only native transition timing seams. It compares validation +reasons, dispatch arguments, public destination events and callback results. +The mod has no engine imports or backend-specific branches. Together with the +existing normal, rollback and generated-starter cases, CI now performs 32 +comparisons / 64 isolated loader runs. + +The local suite additionally drives Gen2Recomped's real native +`startWarpTo` and `Transition` using synthetic map materialization, including +fade/no-fade, music options, native bookkeeping, lock release and one-shot +completion. Further cases cover host-specific locks, malformed maps, callback +reentry, native refusal/exception, lease retirement and API-2 behavior. These +are ROM-free runtime-boundary tests; no retail-ROM playthrough or rendered +arrival-effect equivalence is claimed. diff --git a/docs/modding.md b/docs/modding.md index a48460651..f29c2f6c8 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -23,6 +23,7 @@ luajit tools/gen_registry_docs.lua ../gen1recomp.wiki The [API-3 entry and cross-engine contracts](api3-portability.md) describe using the same manifest and mod source on Gen1Recomp and Gen2Recomped. API 3 selects the unified adapters automatically; API 1/2 keep their defaults. +See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index f5de4a039..6f2de9444 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -2,6 +2,7 @@ -- callable handles but never delegate to a native method with a different contract. local Records = require("src.mods.Api3Records") local Runtime = require("src.mods.Runtime") +local Warp = require("src.mods.Api3Warp") local copy = require("src.mods.Merge").deepCopy local A = {} local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty @@ -12,7 +13,7 @@ local SUPPORTED = {} for name in ([[overworld current activeBlockAt party canReorderParty reorderParty availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters - setFlag getFlag liveMaps queueScript]]):gmatch("%S+") + setFlag getFlag liveMaps queueScript warpTo]]):gmatch("%S+") do SUPPORTED[name] = true end @@ -78,10 +79,14 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) end return nativeWorld end + local warp = supportedGeneration and Warp.new(loader, api.id, gen, current, native) local function dispatch(name, ...) if not supportedGeneration or not SUPPORTED[name] then return nil, "unsupported: API 3 world method " .. name end + if name == "warpTo" then + return warp(...) + end local game = current() if not game then return nil, "no game" diff --git a/src/mods/Api3Warp.lua b/src/mods/Api3Warp.lua new file mode 100644 index 000000000..8deba26b3 --- /dev/null +++ b/src/mods/Api3Warp.lua @@ -0,0 +1,248 @@ +-- API-3 warp admission and callback lifetime over the native overworld path. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Actions = require("src.world.WorldActions") +local W = {} +local contexts = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local pending = setmetatable({}, { __mode = "k" }) +local dirs = { up = true, down = true, left = true, right = true } +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function integer(n) + return type(n) == "number" and n == n and n >= 0 and n < math.huge and n % 1 == 0 +end +local function retire(token) + token.done = true + token.callback = nil + if pending[token.game] == token then + pending[token.game] = nil + end +end +function W.cancelAll(loader, owner) + for candidate, context in pairs(contexts) do + if not loader or candidate == loader then + if owner then + context.owners[owner] = nil + else + contexts[candidate] = nil + end + end + end + for _, token in pairs(pending) do + if (not loader or token.loader == loader) and (not owner or token.owner == owner) then + retire(token) + end + end +end +local function inspect(loader) + for _, token in pairs(pending) do + if token.loader == loader and not token.valid() then + retire(token) + end + end +end +local function observe(loader) + local old = observers[loader] + if old and old[1] == loader.hooks then + return + end + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + -- Installed at facade creation, never spliced into a dispatching hook chain. + loader.hooks:wrap("input.step", function(nextFn, ...) + if next(pending) == nil then + return nextFn(...) + end + inspect(loader) + local result = pack(pcall(nextFn, ...)) + inspect(loader) + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, math.huge, "engine:api3_warp") + for _, event in ipairs({ "map.entered", "map.exited" }) do + local name = event + loader.events:on(name, function(payload) + for _, token in pairs(pending) do + if token.loader == loader then + if not token.valid() then + retire(token) + elseif name == "map.entered" then + if token.entered or payload.mapId ~= token.destination then + retire(token) + else + token.entered = true + end + elseif token.entered then + retire(token) + end + end + end + end, math.huge, "engine:api3_warp") + end +end +function W.new(loader, owner, generation, current, native) + local context = contexts[loader] + if not context then + context = { owners = {} } + contexts[loader] = context + end + local lease = {} + context.owners[owner] = lease + local function licensed() + return contexts[loader] == context and context.owners[owner] == lease + end + observe(loader) + return function(mapId, x, y, facing, options) + if not licensed() then + return nil, "warp facade is retired" + end + local game = current() + if not game then + return nil, "no game" + end + local api = native(game) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local ready, why = Actions.ready(game, ow) + if not ready then + return nil, why + end + inspect(loader) + if pending[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then + return nil, "world is busy" + end + if type(mapId) ~= "string" or #mapId == 0 or #mapId > 256 or mapId:find("%c") then + return nil, "invalid map id" + end + local maps = game.data and game.data.maps + local destination = mapId + if generation == 3 and maps and not maps[destination] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if maps[prefix .. destination] then + destination = prefix .. destination + end + end + local def = maps and maps[destination] + if not def then + return nil, "unknown map: " .. mapId + end + if not integer(x) or not integer(y) then + return nil, "invalid coordinates" + end + local scale = generation == 3 and 1 or 2 + if not integer(def.width) or def.width == 0 or not integer(def.height) or def.height == 0 then + return nil, "invalid map dimensions" + end + if x >= def.width * scale or y >= def.height * scale then + return nil, "coordinates out of bounds" + end + if facing ~= nil and not dirs[facing] then + return nil, "invalid facing" + end + if options ~= nil and (type(options) ~= "table" or getmetatable(options) ~= nil) then + return nil, "invalid options" + end + local opts = {} + for key, value in pairs(options or {}) do + if key == "arrive" then + if value ~= "fly" and value ~= "teleport" then + return nil, "invalid arrival" + end + elseif key == "onDone" then + if type(value) ~= "function" then + return nil, "invalid callback" + end + elseif key == "keepMusic" or key == "fade" then + if type(value) ~= "boolean" then + return nil, "invalid options" + end + if key == "fade" and generation ~= 3 then + return nil, "unsupported fade" + end + else + return nil, "unknown warp option: " .. tostring(key) + end + opts[key] = value + end + facing = facing or (generation ~= 1 and ow.player.facing) or "down" + local hooks, events = Runtime.hooks, Runtime.events + local token = + { game = game, loader = loader, owner = owner, destination = destination, callback = opts.onDone } + local save, data, stack, player, version = game.save, game.data, game.stack, ow.player, Version.get() + function token.valid() + return not token.done + and licensed() + and current() == game + and game.save == save + and game.data == data + and game.stack == stack + and ow.player == player + and Version.get() == version + and Runtime.hooks == hooks + and Runtime.events == events + and native(game):overworld() == ow + end + local accepted, deferred + local function finish(...) + if token.done then + return + end + if accepted == nil then + if not deferred then + deferred = pack(...) + end + return + end + local valid = token.valid() and ow.map and ow.map.id == destination + local callback = valid and token.callback + retire(token) + if not accepted or not callback then + return + end + local previous = Runtime.currentMod + Runtime.currentMod = owner + local result = pack(pcall(callback, ...)) + Runtime.currentMod = previous + if not result[1] then + Runtime.reportError(owner, "world warp callback failed: " .. tostring(result[2])) + end + end + pending[game] = token + local arrival = ow.arriveWarp + if opts.arrive then + ow.arriveWarp = opts.arrive + end + local called, result, detail = pcall( + ow.startWarpTo, + ow, + destination, + x, + y, + facing, + finish, + { via = "warp", keepMusic = opts.keepMusic, fade = opts.fade } + ) + -- The native controller is a void method. Explicit false/refusal and errors + -- never count as admission; a callback fired before that result is deferred. + accepted = called and result ~= false and not (result == nil and detail ~= nil) + if not accepted then + retire(token) + ow.arriveWarp = arrival + if not called then + return nil, "world operation failed: " .. tostring(result) + end + return nil, detail or "world operation failed" + end + if deferred then + finish(unpack(deferred, 1, deferred.n)) + end + return true + end +end +return W diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index ba46218d0..17ecd49e0 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -12,6 +12,7 @@ local Version = require("src.core.Version") local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") local PortableScripts = require("src.mods.PortableScripts") +local Api3Warp = require("src.mods.Api3Warp") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") @@ -1262,6 +1263,7 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + Api3Warp.cancelAll(self, modId) PortableScripts.cancelAll(self, modId, "mod rolled back") for name in pairs(self.journal or self.content) do self.content[name]:rollback(modId) @@ -1377,6 +1379,7 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods reloaded") -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua @@ -1721,6 +1724,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods unloaded") local undone = select(1, ModSeams.unwindAll()) pcall(function() diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 05f6dc9d0..1df5b2b0c 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -14777,7 +14777,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.arriveWarp = nil local fromId = self.map.id local fromSection = self.map.def and self.map.def.regionMapSection - Game.stack:push(Transition.new(Game, function() + local transition = Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) self:noteGen2Spawn(fromId) -- FIRERED'S SIDE STAIRS (ExitStairsMovement): you land ON the stair tile @@ -14967,7 +14967,15 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- side, which is what the scripts already do. if self.player then self.player.inputLocked = false end if onDone then onDone() end - end)) + end) + -- API 3 exposes the Gen 3 no-fade option. Reuse the exact native midpoint + -- and completion path; ordinary/native callers keep their existing fade. + if opts and opts.fade == false then + transition.onMidpoint() + transition.onDone() + else + Game.stack:push(transition) + end end -- Re-read a map record after its data changed (WorldAPI:invalidateMap, diff --git a/src/world/WorldActions.lua b/src/world/WorldActions.lua index d6172f82c..3244aa8a6 100644 --- a/src/world/WorldActions.lua +++ b/src/world/WorldActions.lua @@ -94,6 +94,8 @@ local function ready(game, ow) end return true end +-- Shared admission guard for the API-3 warp adapter. +A.ready = ready local function guardedCall(game, fn, ...) planning[game] = true local result = { pcall(fn, game, ...) } diff --git a/tests/api3_contract/mods/warp/main.lua b/tests/api3_contract/mods/warp/main.lua new file mode 100644 index 000000000..2dd8de861 --- /dev/null +++ b/tests/api3_contract/mods/warp/main.lua @@ -0,0 +1,23 @@ +-- Identical public-only mod on both engines: the harness owns native timing. +local mod = ... +local world = mod.world +assert(mod:supports("world", "warpTo")) +local early, reason = world:warpTo("DEST", 0, 0) +assert(early == nil and reason == "no game") +local calls, args, events = 0, nil, {} +mod.events:on("map.entered", function(ev) + events[#events + 1] = { mapId = ev.mapId, fromMapId = ev.fromMapId, via = ev.via } +end) +mod.exports.request = function(...) + return world:warpTo(...) +end +mod.exports.notify = function(...) + calls = calls + 1 + args = { n = select("#", ...), first = (...), third = select(3, ...) } +end +mod.exports.results = function() + return { calls = calls, args = args, events = events } +end +mod.exports.current = function() + return world:current() +end diff --git a/tests/api3_contract/mods/warp/manifest.json b/tests/api3_contract/mods/warp/manifest.json new file mode 100644 index 000000000..6bc2f80ea --- /dev/null +++ b/tests/api3_contract/mods/warp/manifest.json @@ -0,0 +1 @@ +{"id":"warp","name":"Portable Warp Contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/run.lua b/tests/api3_contract/run.lua index 0024a01d9..c9fe68f51 100644 --- a/tests/api3_contract/run.lua +++ b/tests/api3_contract/run.lua @@ -253,4 +253,5 @@ function M.run(root, backend, version, failing, scaffold) print("API3_CONTRACT " .. Json.encode(result)) return result end +M.dataFor = dataFor return M diff --git a/tests/api3_contract/warp.lua b/tests/api3_contract/warp.lua new file mode 100644 index 000000000..1b1e5d376 --- /dev/null +++ b/tests/api3_contract/warp.lua @@ -0,0 +1,387 @@ +-- Shared driver, real production loaders/adapters, native-shaped world seams. +-- No backend distinction is passed into the mod files or public assertions. +local M = {} +local unpack = table.unpack or unpack +local function read(path) + local f = assert(io.open(path, "rb")) + local value = f:read("*a") + f:close() + return value +end +function M.fixture(root, backend, version) + local Version = require("src.core.GameVersion") + local Runtime = require("src.mods.Runtime") + Version.set(version) + local gen = Version.generation(version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/warp%.lua$") + local data = dofile(codeRoot .. "/run.lua").dataFor(backend, gen) + local prefix = gen == 3 and (version == "emerald" and "EM_" or "FR_") or "" + local home, dest = prefix .. "HOME", prefix .. "DEST" + for _, id in ipairs({ home, dest }) do + data.maps[id] = { + id = id, + width = 2, + height = 3, + tileset = "FIX", + blocks = { 1 }, + midLayout = gen == 3 and { width = 2, height = 3 } or nil, + } + if data.gen2Maps then + data.gen2Maps[id] = data.maps[id] + end + end + local files = {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/warp/" .. name] = read(root .. "/mods/warp/" .. name) + end + local fs = { + read = function(path) + return files[path] + end, + load = function(path) + return loadstring(assert(files[path]), "@" .. path) + end, + getInfo = function(path) + if files[path] then + return { type = "file", size = #files[path] } + end + if path == "mods" or path == "mods/warp" then + return { type = "directory" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { "warp" } or {} + end, + } + local loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = version }) + local f = { loader = loader, gen = gen, home = home, dest = dest, trace = {}, busy = false } + loader._game = function(_, loadedOnly) + assert(backend ~= "gen2" or loadedOnly, "no Game bootstrap") + return f.live + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + f.exports = assert(loader.exports.warp) + local save = { party = {}, flags = {}, modData = {} } + local player = { cellX = 0, cellY = 0, facing = "left" } + local ow = { + isOverworld = true, + player = player, + map = { id = home, def = data.maps[home] }, + maps = data.maps, + runner = { + isRunning = function() + return false + end, + }, + scriptMoves = {}, + } + local stack = { states = { ow } } + function stack:top() + return self.states[#self.states] + end + local game = { data = data, save = save, session = save, stack = stack, phase = "field", overworld = ow } + f.game, f.live, f.ow, f.player = game, game, ow, player + function f:dispatch(id, x, y, facing, opts) + self.trace[#self.trace + 1] = { + mapId = id, + x = x, + y = y, + facing = facing, + arrive = opts.arrive, + keepMusic = opts.keepMusic, + fade = opts.fade, + } + self.callback = opts.onDone + self.destination = { id, x, y, facing } + self.busy, ow.transitioning = true, true + if self.synchronous then + self:arrive() + self:complete("sync", nil, "tail") + end + if self.failure == "throw" then + error("native warp probe", 0) + end + if self.failure == "refuse" then + return false, "warp refused" + end + return true + end + function ow:startWarpTo(id, x, y, facing, onDone, opts) + local arrive = self.arriveWarp + self.arriveWarp = nil + local ok, why = f:dispatch( + id, + x, + y, + facing, + { onDone = onDone, arrive = arrive, keepMusic = opts and opts.keepMusic, fade = opts and opts.fade } + ) + if not ok then + return ok, why + end + -- Native Gen 1/controller returns no value on successful admission. + end + function ow:acceptsMenuInput() + return not (f.busy or player.moving) + end + function ow:warpToMapId(id, x, y, facing, opts) + return f:dispatch(id, x, y, facing, opts or {}) + end + if backend == "gen1" and gen == 2 then + game.world = ow + end + if backend == "gen1" and gen == 3 then + local field = { running = true, _game = game, _session = save, _locks = {} } + f.field = field + package.loaded["src.core.game3.field"] = field + package.loaded["src.core.game3.runtime"] = { + getSession = function() + return game.session + end, + uiBusy = function() + return f.busy + end, + } + local map = { + current = home, + currentDef = function() + return data.maps[f.map.current] + end, + } + f.map = map + package.loaded["src.core.game3.map"] = map + package.loaded["src.core.game3.player"] = player + package.loaded["src.core.game3.scripting.space"] = {} + package.loaded["src.core.game3.battle.init"] = { + isActive = function() + return false + end, + } + package.loaded["src.core.game3.warp"] = { + isBusy = function() + return f.busy + end, + request = function(_, owner, id, x, y, facing, opts) + assert(owner == game, "native request retains Game") + return f:dispatch(id, x, y, facing, opts) + end, + } + end + function f:arrive(id) + local d = assert(self.destination) + local from = ow.map.id + Runtime.emit("map.exited", { mapId = from, toMapId = id or d[1] }) + ow.map = { id = id or d[1], def = data.maps[id or d[1]] } + player.cellX, player.cellY, player.facing = d[2], d[3], d[4] + if self.map then + self.map.current = ow.map.id + end + Runtime.emit("map.entered", { mapId = ow.map.id, fromMapId = from, via = "warp", map = ow.map }) + end + function f:complete(...) + self.busy, ow.transitioning = false, false + if self.callback then + self.callback(...) + end + end + function f:update() + local name = backend == "gen1" and "core.update" or "input.step" + return loader.hooks:call(name, function() + return "updated", nil, "tail" + end, game, 0) + end + return f +end +function M.run(root, backend, version) + local Runtime = require("src.mods.Runtime") + local Json = require("src.link.Json") + local report = { invalid = {}, busy = {}, successful = {} } + local function fixture() + return M.fixture(root, backend, version) + end + local f = fixture() + local function refused(args, expected) + local before = #f.trace + local ok, why = f.exports.request(unpack(args, 1, args.n or #args)) + assert(ok == nil and why == expected, tostring(why) .. " ~= " .. expected) + assert(#f.trace == before and f.ow.map.id == f.home and f.player.cellX == 0) + assert(f.ow.arriveWarp == nil and #f.exports.results().events == 0) + report.invalid[#report.invalid + 1] = why + end + refused({ false, 0, 0 }, "invalid map id") + refused({ "", 0, 0 }, "invalid map id") + refused({ "bad\nmap", 0, 0 }, "invalid map id") + refused({ string.rep("x", 257), 0, 0 }, "invalid map id") + refused({ "UNKNOWN", 0, 0 }, "unknown map: UNKNOWN") + for _, coordinate in ipairs({ -1, 0.5, math.huge, -math.huge, 0 / 0, "0", false }) do + refused({ f.dest, coordinate, 0 }, "invalid coordinates") + refused({ f.dest, 0, coordinate }, "invalid coordinates") + end + local scale = f.gen == 3 and 1 or 2 + refused({ f.dest, 2 * scale, 0 }, "coordinates out of bounds") + refused({ f.dest, 0, 3 * scale }, "coordinates out of bounds") + refused({ f.dest, 0, 0, "north" }, "invalid facing") + refused({ f.dest, 0, 0, "up", false }, "invalid options") + refused({ f.dest, 0, 0, "up", { arrive = "boat" } }, "invalid arrival") + refused({ f.dest, 0, 0, "up", { onDone = false } }, "invalid callback") + refused({ f.dest, 0, 0, "up", { keepMusic = 1 } }, "invalid options") + refused({ f.dest, 0, 0, "up", { fade = 1 } }, "invalid options") + refused({ f.dest, 0, 0, "up", { extra = true } }, "unknown warp option: extra") + if f.gen ~= 3 then + refused({ f.dest, 0, 0, "up", { fade = false } }, "unsupported fade") + end + for _, lock in ipairs({ "transitioning", "moving", "script", "overlay", "modscript" }) do + f = fixture() + if lock == "transitioning" then + f.ow.transitioning, f.busy = true, true + if f.field then + f.field.locked = true + end + elseif lock == "moving" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + f.busy = true + if f.field then + package.loaded["src.core.game3.scripting.space"] = + { vm = { + isRunning = function() + return true + end, + } } + end + elseif lock == "overlay" then + f.game.stack.states[2] = {} + f.busy = true + else + f.ow._modScriptLock = {} + f.busy = true + if f.field then + f.field._modScriptLock = {} + end + end + local ok, why = f.exports.request(f.dest, 0, 0) + assert(ok == nil and type(why) == "string" and #f.trace == 0, lock .. ": " .. tostring(why)) + report.busy[#report.busy + 1] = lock + end + for _, options in ipairs({ + {}, + { arrive = "fly", keepMusic = true }, + { arrive = "teleport", keepMusic = false }, + }) do + f = fixture() + options.onDone = f.exports.notify + assert(f.exports.request(f.dest, 2 * scale - 1, 3 * scale - 1, "right", options)) + assert(f.exports.results().calls == 0 and #f.trace == 1) + local expectedArrival, expectedMusic = options.arrive, options.keepMusic + options.arrive, options.keepMusic, options.onDone = + "changed", true, function() + error("mutable options") + end + assert(f.trace[1].arrive == expectedArrival and f.trace[1].keepMusic == expectedMusic) + f:arrive() + f:complete("done", nil, "tail") + f:complete("replay") + assert(f.exports.results().calls == 1 and f.exports.results().args.n == 3) + assert(f.exports.results().args.first == "done" and f.exports.results().args.third == "tail") + local location = assert(f.exports.current()) + assert(location.mapId == f.dest and location.x == 2 * scale - 1 and location.y == 3 * scale - 1) + report.successful[#report.successful + 1] = { native = f.trace[1], result = f.exports.results() } + end + f = fixture() + assert(f.exports.request(f.dest, 0, 0)) + assert(f.trace[1].facing == (f.gen == 1 and "down" or "left")) + f:arrive() + f:complete() + report.defaultFacing = f.trace[1].facing + if f.gen == 3 then + f = fixture() + assert(f.exports.request("DEST", 1, 2, "up", { fade = false, onDone = f.exports.notify })) + assert(f.trace[1].mapId == f.dest and f.trace[1].fade == false) + f:arrive() + f:complete() + report.gen3Alias = true + end + f = fixture() + f.synchronous = true + assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify })) + assert(f.exports.results().calls == 1 and f.exports.results().args.first == "sync") + report.synchronous = true + for _, change in ipairs({ "game", "save", "world", "bus", "owner", "other_map" }) do + f = fixture() + assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify })) + f:arrive() + local restore + if change == "game" then + local old = f.live + f.live = {} + restore = function() + f.live = old + end + elseif change == "save" then + local old = f.game.save + f.game.save = {} + f.game.session = f.game.save + restore = function() + f.game.save = old + f.game.session = old + end + elseif change == "world" then + if backend == "gen1" and f.gen == 3 then + local old = package.loaded["src.core.game3.field"] + package.loaded["src.core.game3.field"] = { running = true } + restore = function() + package.loaded["src.core.game3.field"] = old + end + else + local old = f.game.stack.states + f.game.stack.states = {} + f.game.overworld = nil + f.game.world = nil + restore = function() + f.game.stack.states = old + f.game.overworld = f.ow + if f.gen == 2 then + f.game.world = f.ow + end + end + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = require("src.mods.Events").new() + restore = function() + Runtime.events = old + end + elseif change == "owner" then + f.loader:_rollback("warp") + else + f:arrive(f.home) + end + f:update() + -- A stale native completion must suppress the callback permanently. + f:complete("stale") + if restore then + restore() + end + f:complete("revived") + assert(f.exports.results().calls == 0, change .. " callback survived invalidation") + end + report.stale = true + f = fixture() + assert(f.exports.request(f.dest, 0, 0, "up", { + onDone = function() + error("authored warp failure", 0) + end, + })) + f:arrive() + f:complete() + f:complete() + assert(#f.loader.errors == 1 and f.loader.errors[1]:find("warp: world warp callback failed:", 1, true)) + assert(Runtime.currentMod == nil) + report.attribution = true + print("API3_CONTRACT " .. Json.encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index b7dcb960d..c3766dea8 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -3,8 +3,15 @@ package.path = "./?.lua;./?/init.lua;" .. package.path love = nil local root, backend, version, mode = arg[1], arg[2], arg[3], arg[4] assert( - root and backend and version and (mode == "normal" or mode == "rollback" or mode == "scaffold"), + root + and backend + and version + and (mode == "normal" or mode == "rollback" or mode == "scaffold" or mode == "warp"), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") +if mode == "warp" then + dofile(codeRoot .. "/warp.lua").run(root, backend, version) +else + dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") +end diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index a34fe4ae9..4e5ca3a7f 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -134,6 +134,7 @@ do T.eq(type(world.party), "function", "early world method callable") T.eq(type(battle.snapshot), "function", "early battle method callable") for _, name in ipairs({ + "warpTo", "party", "current", "activeBlockAt", @@ -145,7 +146,6 @@ do T.eq(m:supports("world", name), true, "implemented world method " .. name) end for _, name in ipairs({ - "warpTo", "toggleObject", "replaceBlock", "spawnNpc", @@ -217,6 +217,7 @@ end for _, version in ipairs({ "platinum", "black" }) do local m = boot(3, nil, false, version) T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) + T.eq(m:supports("world", "warpTo"), false, "no shared warp promised for " .. version) local value, why = m.world:party() T.eq(value, nil, "unsupported generation call refuses") T.check(why:find("unsupported:", 1, true), "unsupported generation is explicit") diff --git a/tests/engine/api3_warp.lua b/tests/engine/api3_warp.lua new file mode 100644 index 000000000..0bc8ddbb1 --- /dev/null +++ b/tests/engine/api3_warp.lua @@ -0,0 +1,249 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.warp") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared warp contract: " .. tostring(why)) +end +for _, lock in ipairs({ + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", + "parallelRunners", + "parallelQueue", + "pendingScripts", + "scriptMoves", + "stairExit", + "spinning", + "inputLocked", + "fieldMove", + "linkSession", + "linkNet", +}) do + local f = fixture() + if lock == "linkSession" then + f.game.linkSession = {} + elseif lock == "linkNet" then + f.game.linkNet = { closed = false } + elseif lock == "stairExit" or lock == "spinning" or lock == "inputLocked" or lock == "fieldMove" then + f.player[lock] = true + elseif lock:find("Runners") or lock:find("Queue") or lock == "pendingScripts" or lock == "scriptMoves" then + f.ow[lock] = { {} } + else + f.ow[lock] = true + end + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, lock .. " declines admission") + T.eq(why, "world is busy", lock .. " explains busy state") + T.eq(#f.trace, 0, lock .. " never dispatches") +end +do + local f = fixture() + f.game.save = nil + T.eq(f.exports.request(f.dest, 0, 0), nil, "missing save refuses") + f.live = nil + T.eq(f.exports.request(f.dest, 0, 0), nil, "missing game refuses") + T.eq(#f.trace, 0, "missing context never dispatches") +end +for _, size in ipairs({ false, -1, 0, 0.5, math.huge, 0 / 0 }) do + local f = fixture() + f.game.data.maps[f.dest].width = size + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, "malformed map dimensions refuse") + T.eq(why, "invalid map dimensions", "dimensions diagnosed before native work") + T.eq(#f.trace, 0, "malformed maps never dispatch") +end +do + local f = fixture() + local opts = setmetatable({}, { + __index = function() + error("must not run") + end, + }) + local ok, why = f.exports.request(f.dest, 0, 0, "down", opts) + T.eq(ok, nil, "metatable options rejected") + T.eq(why, "invalid options", "plain options required") + T.eq(#f.trace, 0, "metatable never reaches native warp") +end +for _, change in ipairs({ "data", "stack", "player", "version" }) do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start " .. change) + f:arrive() + local restore + if change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + else + local obj = change == "player" and f.ow or f.game + local previous = obj[change] + obj[change] = {} + restore = function() + obj[change] = previous + end + end + f:update() + restore() + f:complete() + T.eq(f.exports.results().calls, 0, change .. " cannot revive callback after observed change") +end +for _, failure in ipairs({ "refuse", "throw" }) do + local f = fixture() + f.failure, f.synchronous = failure, true + f.ow.arriveWarp = "original" + local ok, why = f.exports.request(f.dest, 0, 0, "up", { arrive = "fly", onDone = f.exports.notify }) + T.eq(ok, nil, "native " .. failure .. " is not admitted") + T.check(type(why) == "string", "native failure explained") + T.eq(f.exports.results().calls, 0, "synchronous callback waits for native acceptance") + T.eq(f.ow.arriveWarp, "original", "failed dispatch restores borrowed arrival field") + f:complete() + T.eq(f.exports.results().calls, 0, "failed callback cannot replay") +end +do + local f = fixture() + f.synchronous = true + local ok = f.exports.request(f.dest, 0, 0, "up", { + onDone = function(...) + T.eq(Runtime.currentMod, "warp", "callback attributed to owning mod") + f.exports.notify(...) + f.synchronous = false + T.check( + f.exports.request(f.home, 1, 1, "down", { onDone = f.exports.notify }), + "callback may start next warp" + ) + end, + }) + T.check(ok, "synchronous native arrival admitted") + T.eq(#f.trace, 2, "one reentrant request dispatches once") + T.eq(Runtime.currentMod, nil, "callback restores previous runtime owner") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 2, "old completion does not clear next request") +end +for _, operation in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start before " .. operation) + f:arrive() + if operation == "rollback" then + f.loader:_rollback("warp") + elseif operation == "reload" then + fixture() -- A new loader/session retires every previous facade. + else + Loader.teardown() + end + f:complete() + T.eq(f.exports.results().calls, 0, operation .. " retires callback") + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, operation .. " retires facade") + T.eq(why, "warp facade is retired", "stale facade diagnostic") +end +for _, direction in ipairs({ "up", "down", "left", "right" }) do + local f = fixture() + T.check(f.exports.request(f.home, 1, 1, direction, { onDone = f.exports.notify }), "same-map " .. direction) + T.eq(f.trace[1].facing, direction, "direction reaches native warp unchanged") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 1, "same-map arrival not mistaken for stale completion") +end +do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start before false arrival") + f:complete() + T.eq(f.exports.results().calls, 0, "completion on old map never invokes mod") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 0, "false completion cannot be revived by later arrival") +end +-- Exercise the actual native transition; only map materialization is synthetic. +love = require("tests.love_stub") +local OW = require("src.world.OverworldController") +local Transition = require("src.render.Transition") +local StateStack = require("src.core.StateStack") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.startWarpTo, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.startWarpTo, i, game) + return function() + debug.setupvalue(OW.startWarpTo, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, fade in ipairs({ true, false }) do + if fade or Version.generation(version) == 3 then + local f = fixture(version) + setmetatable(f.game.stack, { __index = StateStack }) + f.ow.startWarpTo = OW.startWarpTo + local restore = bind(f.game) + function f.ow:frlgBehaviours() + return false + end + function f.ow:noteGen2Spawn(from) + self.notedFrom = from + end + function f.ow:setMap(id, x, y, facing, opts) + f.destination = { id, x, y, facing } + T.eq(opts.via, "warp", "native transition retains warp reason") + T.eq(opts.keepMusic, true, "native transition retains music request") + f:arrive() + end + local opts = { onDone = f.exports.notify, keepMusic = true } + if f.gen == 3 then + opts.fade = fade + end + T.check(f.exports.request(f.dest, 1, 2, "right", opts), "actual native warp admitted") + if fade then + local state = f.game.stack:top() + T.eq(getmetatable(state), Transition, "ordinary warp pushes native fade") + T.eq(f.exports.results().calls, 0, "callback waits for native transition") + for _ = 1, state.frames + state.framesIn + 2 do + if f.game.stack:top() == state then + state:update(1 / 60) + end + end + else + T.eq(#f.game.stack.states, 1, "no-fade warp creates no transition screen") + end + T.eq(f.ow.map.id, f.dest, "native transition arrives at destination") + T.eq(f.ow.notedFrom, f.home, "native spawn bookkeeping retained") + T.same(f.ow.warpEntryCell, { x = 1, y = 2 }, "native arrival tile guard retained") + T.eq(f.ow.transitioning, false, "native transition releases lock") + T.eq(f.exports.results().calls, 1, "actual native callback runs exactly once") + restore() + end + end +end +-- Legacy public facade continues to ignore fade, rather than opting into API 3. +do + local f = fixture("firered") + local native = require("src.world.WorldAPI").new(f.game, "legacy") + T.check(native:warpTo(f.dest, -1, 0, "north", { fade = false }), "legacy dispatch behavior unchanged") + T.eq(f.trace[1].fade, nil, "legacy fade option remains ignored") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 portable warp") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 48ba0eb9b..52ef00c31 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold"): + for mode in ("normal", "rollback", "scaffold", "warp"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 2ea78e3db7a07532c6ab08a2c0cd0e7b7edeee5d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:56:22 +0200 Subject: [PATCH 14/71] Add API 3 object visibility and block edits with shared contracts --- docs/api3-portability.md | 15 +- docs/api3-warp.md | 4 +- docs/api3-world-edits.md | 124 ++++++++++ docs/modding.md | 3 +- src/mods/Api3.lua | 15 +- src/mods/Api3Warp.lua | 4 +- src/mods/Api3WorldEdits.lua | 154 ++++++++++++ src/script/Gen2Commands.lua | 6 + tests/api3_contract/mods/edits/main.lua | 37 +++ tests/api3_contract/mods/edits/manifest.json | 1 + tests/api3_contract/warp.lua | 11 +- tests/api3_contract/worker.lua | 12 +- tests/api3_contract/world_edits.lua | 230 ++++++++++++++++++ tests/engine/api3_compat.lua | 6 +- tests/engine/api3_world_edits.lua | 241 +++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 16 files changed, 844 insertions(+), 21 deletions(-) create mode 100644 docs/api3-world-edits.md create mode 100644 src/mods/Api3WorldEdits.lua create mode 100644 tests/api3_contract/mods/edits/main.lua create mode 100644 tests/api3_contract/mods/edits/manifest.json create mode 100644 tests/api3_contract/world_edits.lua create mode 100644 tests/engine/api3_world_edits.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index a484539fa..0c7b584ed 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -77,11 +77,13 @@ The common world calls currently enabled for generations 1–3 are: `canFly`, `flyTo`; - `getFlag`/`setFlag` for private `mod:` boolean flags; - `queueScript` for the existing bounded portable script language; -- [`warpTo`](api3-warp.md) for validated native map transitions. +- [`warpTo`](api3-warp.md) for validated native map transitions; +- [`toggleObject` and `replaceBlock`](api3-world-edits.md) for validated native + visibility and active block/metatile edits. Check `mod:supports("world", name)` before relying on a call. The other Gen1 world method names remain callable but explicitly return -`nil, "unsupported: ..."`: object/block mutations, runtime NPC operations, +`nil, "unsupported: ..."`: runtime NPC operations, `startWildBattle` and `invalidateMap` have not yet been verified against the Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. @@ -128,8 +130,9 @@ the mod fixtures contain no engine imports or engine-specific branches. The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp -cases use the same public mod on both engines and native-shaped timing seams: -32 comparisons and 64 isolated loader runs. It compares JSON projections of public results and +cases use the same public mod on both engines and native-shaped timing seams. +Eight object/block cases extend this to 40 comparisons and 80 isolated loader +runs. It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge @@ -139,7 +142,9 @@ writeback, custom events, unsupported-name handling, frozen content and failed owner cleanup. Warp cases also compare validation errors, native dispatch arguments, destination events, boundary coordinates, arrival options, callback argument holes/replay, stale sessions/owners, synchronous completion and error -attribution. Separate Gen2 tests check API-1/2 behavior, contradictory +attribution. Object/block cases compare native reference routing, visibility/flag +changes, block geometry/bounds, native reads and one event per successful edit. +Separate Gen2 tests check API-1/2 behavior, contradictory manifest fields, honest availability and malformed flag stores. CI pins the same external commit and uploads the report. diff --git a/docs/api3-warp.md b/docs/api3-warp.md index 4b86542dc..dbb19a6a8 100644 --- a/docs/api3-warp.md +++ b/docs/api3-warp.md @@ -118,8 +118,8 @@ Emerald. The shared driver builds each engine's native-shaped Game/world data and replaces only native transition timing seams. It compares validation reasons, dispatch arguments, public destination events and callback results. The mod has no engine imports or backend-specific branches. Together with the -existing normal, rollback and generated-starter cases, CI now performs 32 -comparisons / 64 isolated loader runs. +existing normal, rollback, generated-starter and object/block cases, CI now +performs 40 comparisons / 80 isolated loader runs. The local suite additionally drives Gen2Recomped's real native `startWarpTo` and `Transition` using synthetic map materialization, including diff --git a/docs/api3-world-edits.md b/docs/api3-world-edits.md new file mode 100644 index 000000000..4297c602f --- /dev/null +++ b/docs/api3-world-edits.md @@ -0,0 +1,124 @@ +# API-3 object visibility and block edits + +API-3 mods can call the same public methods on both engines: + +```lua +local mod = ... +local world = mod.world -- Retain at entry; invoke later from an idle world. + +local function openGate(mapId, objectRef, blockX, blockY, openBlock) + if not mod:supports("world", "toggleObject") then return end + local ok, why = world:toggleObject(mapId, objectRef, false) + if not ok then return nil, why end + if mod:supports("world", "replaceBlock") then + return world:replaceBlock(blockX, blockY, openBlock) + end +end +``` + +Each call returns `true` or `nil, reason`. Calls are independent, not an atomic +multi-command transaction. Support is advertised for generations 1–3; API-3 +Gen4/5 still refuse both methods. A captured facade follows the current Game +without bootstrapping one. Rollback, reload and teardown retire its mutation +lease; retained calls return `nil, "world facade is retired"`. API-1/2 behavior +and the portable script vocabulary are unchanged. + +Both methods require a save and an idle, topmost overworld. Native scripts, +portable scripts, queued/parallel scripts, menus, movement, transitions, +controller locks and link sessions refuse admission. Reentrant object/block +edits from an edit's own event/native callback return `nil, "world is busy"`; +the guard is released on success and native exceptions. + +## `toggleObject(mapId, objectRef, visible)` + +`mapId` and string references are nonempty strings of at most 256 bytes without +control characters. Numeric references must be finite nonnegative integers; +`visible` must be an actual boolean. The map must exist in the active merged +dataset. Gen3 also resolves unprefixed map names using the current version's +prefix when an exact ID is absent, as `warpTo` does. + +Reference routing and persistence deliberately retain the Gen1 API-3/native +contracts, rather than treating all generations' object numbers as identical: + +| Generation | Reference | Scope and effects | +| --- | --- | --- | +| 1 | Native visibility key, normally the object's `name`; numeric keys are also accepted | May name an inactive map or a key with no currently defined actor. Writes `save.objectToggles`. An active map refresh uses the native seamless reload, retaining player cell/facing and music. | +| 2 | 1-based position in `map.def.objects`, or its `name` | Active map only. Resolves the public slot to its native template and uses the same effect helper as native appear/disappear. Writes the object's event flag and visit-scoped visibility, synchronizing only that actor. | +| 3 | Native local object ID, `name` or `id` | Active map only. Uses native add/remove commands, including event flags, spawned-object state and flagless visit state; synchronizes only the selected actor. | + +This API does not make map/object IDs portable between cartridges: select a +reference from the active dataset. On Gen2/3, an unknown reference returns +`nil, "no such object: "`; an inactive map returns +`nil, "map is not active: "`. Missing object lists and malformed native +object/store metadata refuse before native writes. Gen1's key store keeps its +existing ability to prepare a later map's visibility. + +Flagged Gen2/3 objects use their native persistent flags. Flagless objects +retain native visit-scoped behavior. A targeted sync does not respawn, relocate +or re-derive unrelated live actors, even when several templates share a flag. +Other native visibility gates (time, story flags, script-spawn rules) still +apply; acceptance is not a promise to override every native gate. + +A successful call emits one `world.object_toggled` event with +`mapId`, `objName = objectRef` and the boolean `visible`. Repeating an accepted +value still follows the native call/event contract. Gen1's active refresh may +also produce its normal native map lifecycle events. Gen2/3 do not reload the +whole map for this operation. + +## `replaceBlock(blockX, blockY, blockId)` + +This changes only the active map. Coordinates are zero-based whole **block** +coordinates, with `0 <= x < map.width` and `0 <= y < map.height`: + +| Generation | One coordinate unit | Valid ID range | +| --- | --- | --- | +| 1/2 | 32-pixel block, 2×2 walk cells | 0–255, and the block must exist in the active tileset | +| 3 | 16-pixel metatile, one walk cell | 0–1023 | + +Coordinates/IDs must be finite integers. Gen3 range validation does not promise +that every ID has artwork in a particular tileset. The adapter checks the +current block, runtime geometry and redraw entry before mutation. Unknown +blocks, unavailable geometry/renderers, fractions, NaN/infinity, negatives and +out-of-bounds coordinates are declined without writing a patch or emitting an +event. Typical reasons are `invalid block id`, `unknown block id`, +`invalid block coordinates`, `block coordinates out of bounds`, +`block unavailable`, `unsupported block geometry` and +`block renderer unavailable`. + +Gen1/2 use the existing native world block replacement. Gen3 uses the host's +native metatile command with its default non-impassable setting, including +clearing old collision/shut-cell overrides, and redraws the runtime map. +Terrain/collision tables remain native; there is no invented shared collision +registry. `activeBlockAt` immediately sees the change. One successful operation +emits one `world.block_replaced` event with `mapId`, `bx`, `by` and `block`. + +On Gen2Recomped this is an instance patch, leaving generated map data intact. +It is not a persistent content edit: permanent layout changes belong in the +map registry. Native execution failures can occur after a native write and do +not promise transactional rollback; preflight refusals have no such effects. + +## Verification + +```sh +luajit tests/engine/api3_world_edits.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The existing Gen1 pin stays at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same new fixture manifest/source +runs under both production loaders for Red, Blue, Yellow, Gold, Silver, +Crystal, FireRed and Emerald. Engine-specific setup stays in the shared +driver, never in the mod. Native-shaped map/render/object seams let the driver +compare refusal reasons, visibility/flag effects, native block reads, geometry +and event payloads. The full checker now runs 40 comparisons / 80 isolated +processes, including all previous entry, rollback, generated-starter and warp +cases. + +Local tests additionally exercise the actual Gen2Recomped native Map, +OverworldState block replacement and object synchronization with synthetic +pooled actors, including shared flags, retained positions, flagless resets, +packed metatiles, collision clearing, malformed stores, reentrancy and retired +owners. These are ROM-free runtime-boundary tests; they do not establish +rendered retail-ROM equivalence or full scene-playthrough coverage. diff --git a/docs/modding.md b/docs/modding.md index f29c2f6c8..153ac03e7 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -23,7 +23,8 @@ luajit tools/gen_registry_docs.lua ../gen1recomp.wiki The [API-3 entry and cross-engine contracts](api3-portability.md) describe using the same manifest and mod source on Gen1Recomp and Gen2Recomped. API 3 selects the unified adapters automatically; API 1/2 keep their defaults. -See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime. +See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime, +and [API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 6f2de9444..29d7f0139 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -3,6 +3,7 @@ local Records = require("src.mods.Api3Records") local Runtime = require("src.mods.Runtime") local Warp = require("src.mods.Api3Warp") +local Edits = require("src.mods.Api3WorldEdits") local copy = require("src.mods.Merge").deepCopy local A = {} local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty @@ -13,7 +14,7 @@ local SUPPORTED = {} for name in ([[overworld current activeBlockAt party canReorderParty reorderParty availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters - setFlag getFlag liveMaps queueScript warpTo]]):gmatch("%S+") + setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock]]):gmatch("%S+") do SUPPORTED[name] = true end @@ -79,7 +80,10 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) end return nativeWorld end - local warp = supportedGeneration and Warp.new(loader, api.id, gen, current, native) + local warp, licensed + if supportedGeneration then + warp, licensed = Warp.new(loader, api.id, gen, current, native) + end local function dispatch(name, ...) if not supportedGeneration or not SUPPORTED[name] then return nil, "unsupported: API 3 world method " .. name @@ -87,10 +91,17 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) if name == "warpTo" then return warp(...) end + local edit = name == "toggleObject" or name == "replaceBlock" + if edit and not licensed() then + return nil, "world facade is retired" + end local game = current() if not game then return nil, "no game" end + if edit then + return Edits.call(game, native(game), gen, engineRequire, name, ...) + end if name == "setFlag" or name == "getFlag" then if not game.save then return nil, "no save" diff --git a/src/mods/Api3Warp.lua b/src/mods/Api3Warp.lua index 8deba26b3..400465d87 100644 --- a/src/mods/Api3Warp.lua +++ b/src/mods/Api3Warp.lua @@ -96,7 +96,7 @@ function W.new(loader, owner, generation, current, native) return contexts[loader] == context and context.owners[owner] == lease end observe(loader) - return function(mapId, x, y, facing, options) + local function warp(mapId, x, y, facing, options) if not licensed() then return nil, "warp facade is retired" end @@ -244,5 +244,7 @@ function W.new(loader, owner, generation, current, native) end return true end + -- Synchronous API-3 edits share this owner lease and loader lifecycle. + return warp, licensed end return W diff --git a/src/mods/Api3WorldEdits.lua b/src/mods/Api3WorldEdits.lua new file mode 100644 index 000000000..522adcb04 --- /dev/null +++ b/src/mods/Api3WorldEdits.lua @@ -0,0 +1,154 @@ +-- Validated API-3 object/block edits over this host's native runtime paths. +local Actions = require("src.world.WorldActions") +local Reads = require("src.mods.WorldReads") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local E = {} +local running = setmetatable({}, { __mode = "k" }) +local function integer(value, maximum) + return type(value) == "number" + and value == value + and value >= 0 + and value < math.huge + and value % 1 == 0 + and (not maximum or value <= maximum) +end +local function identifier(value) + return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c") +end +local function store(value) + return value == nil or type(value) == "table" +end +local function toggle(game, ow, api, gen, req, mapId, ref, visible) + if not identifier(mapId) or not (identifier(ref) or integer(ref)) or type(visible) ~= "boolean" then + return nil, "invalid object toggle" + end + local maps = game.data and game.data.maps + local id = mapId + if gen == 3 and maps and not maps[id] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if maps[prefix .. id] then + id = prefix .. id + end + end + local def = maps and maps[id] + if not def then + return nil, "unknown map: " .. mapId + end + local save = game.save + if not store(save.objectToggles) or not store(save.objectToggles and save.objectToggles[id]) then + return nil, "invalid object toggle store" + end + if not store(save.flags) then + return nil, "invalid flag store" + end + if gen == 1 then + -- Gen 1 permits off-map keys and resolves their visibility on map load. + return api:toggleObject(id, ref, visible) + end + if ow.map.id ~= id then + return nil, "map is not active: " .. mapId + end + local objects = ow.map.def and ow.map.def.objects + if type(objects) ~= "table" then + return nil, "map has no objects" + end + local object + for i, candidate in ipairs(objects) do + local numeric = gen == 2 and i or tonumber(candidate.localId or candidate.index) + if numeric == ref or candidate.name == ref or gen == 3 and candidate.id == ref then + object = candidate + break + end + end + if not object then + return nil, "no such object: " .. tostring(ref) + end + if type(ow.syncObjectVisibility) ~= "function" then + return nil, "object runtime unavailable" + end + local ctx = { game = game, save = save, overworld = ow, mapId = id } + if gen == 2 then + if not store(save.g2ObjectToggles) then + return nil, "invalid object toggle store" + end + req("src.script.Gen2Commands").setObjectVisibility(ctx, object, id, visible) + else + local index = object.index + if not integer(index, 254) or index == 0 then + return nil, "invalid object definition" + end + if + not store(save.gen3Spawned) + or not store(save.gen3Spawned and save.gen3Spawned[id]) + or not store(save.gen3SessionObjects) + then + return nil, "invalid object toggle store" + end + req("src.script.Gen3Commands") + local commands = req("src.script.Commands") + local command = visible and commands.g3_show_object or commands.g3_hide_object + command(ctx, index) + end + Runtime.emit("world.object_toggled", { mapId = id, objName = ref, visible = visible }) + return true +end +local function replace(game, ow, api, gen, req, bx, by, block) + if not integer(block, gen == 3 and 1023 or 255) then + return nil, "invalid block id" + end + local present, metadata = Reads.activeBlockAt(ow.map, ow.map.id, bx, by) + if present == nil then + return nil, metadata + end + local cells = gen == 3 and 1 or 2 + if ow.map.blockCells ~= cells then + return nil, "unsupported block geometry" + end + if gen ~= 3 and not (ow.map.tileset and ow.map.tileset.blocks and ow.map.tileset.blocks[block + 1]) then + return nil, "unknown block id" + end + local renderer = ow.map.renderer + if not renderer or type(renderer.rebuild) ~= "function" then + return nil, "block renderer unavailable" + end + if gen ~= 3 then + return api:replaceBlock(bx, by, block) + end + -- Like Gen1's Gen3 Field.setMetatile default, use the native non-impassable + -- metatile command; it replaces the old collision override as well. + req("src.script.Gen3Commands") + req("src.script.Commands").g3_set_metatile( + { game = game, save = game.save, overworld = ow }, + bx, + by, + block, + 0 + ) + renderer:rebuild() + ow.map.blocksDirty = nil + Runtime.emit("world.block_replaced", { mapId = ow.map.id, bx = bx, by = by, block = block }) + return true +end +function E.call(game, api, generation, req, name, ...) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local ok, reason = Actions.ready(game, ow) + if not ok then + return nil, reason + end + if running[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then + return nil, "world is busy" + end + running[game] = true + local operation = name == "toggleObject" and toggle or replace + local called, value, why = pcall(operation, game, ow, api, generation, req, ...) + running[game] = nil + if not called then + error(value, 0) + end + return value, why +end +return E diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..7219012da 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -1098,6 +1098,12 @@ function Commands.g2_object(ctx, index, visible) obj, mapId = objectByIndex(ctx, index) end if not obj or not mapId then return end + return Gen2Commands.setObjectVisibility(ctx, obj, mapId, visible) +end + +-- Shared native effect after resolving a map object. Public API adapters can +-- supply the resolved template without depending on cached script-ID numbering. +function Gen2Commands.setObjectVisibility(ctx, obj, mapId, visible) local OverworldState = require("src.world.OverworldController") local name = OverworldState.objectToggleKey(obj) -- The PERSISTENT half, which this used to leave to a `setevent` the ROM diff --git a/tests/api3_contract/mods/edits/main.lua b/tests/api3_contract/mods/edits/main.lua new file mode 100644 index 000000000..d26c5321e --- /dev/null +++ b/tests/api3_contract/mods/edits/main.lua @@ -0,0 +1,37 @@ +-- Identical source on both engines; native layout stays in the driver. +local mod = ... +local world = mod.world +assert(mod:supports("world", "toggleObject")) +assert(mod:supports("world", "replaceBlock")) +local ok, why = world:toggleObject("HOME", "NPC", false) +assert(ok == nil and why == "no game") +ok, why = world:replaceBlock(0, 0, 7) +assert(ok == nil and why == "no game") +local events, reenter, nested = {}, false, nil +mod.events:on("world.object_toggled", function(ev) + events[#events + 1] = { kind = "object", mapId = ev.mapId, ref = ev.objName, visible = ev.visible } + if reenter then + nested = { world:replaceBlock(0, 0, 7) } + end +end) +mod.events:on("world.block_replaced", function(ev) + events[#events + 1] = { kind = "block", mapId = ev.mapId, x = ev.bx, y = ev.by, block = ev.block } +end) +mod.exports.toggle = function(...) + return world:toggleObject(...) +end +mod.exports.replace = function(...) + return world:replaceBlock(...) +end +mod.exports.read = function(...) + return world:activeBlockAt(...) +end +mod.exports.events = function() + return events +end +mod.exports.reenter = function(value) + reenter = value +end +mod.exports.nested = function() + return nested +end diff --git a/tests/api3_contract/mods/edits/manifest.json b/tests/api3_contract/mods/edits/manifest.json new file mode 100644 index 000000000..8d0912a07 --- /dev/null +++ b/tests/api3_contract/mods/edits/manifest.json @@ -0,0 +1 @@ +{"id":"edits","name":"Portable World Edits","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/warp.lua b/tests/api3_contract/warp.lua index 1b1e5d376..5e7493d19 100644 --- a/tests/api3_contract/warp.lua +++ b/tests/api3_contract/warp.lua @@ -8,7 +8,8 @@ local function read(path) f:close() return value end -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) + modId = modId or "warp" local Version = require("src.core.GameVersion") local Runtime = require("src.mods.Runtime") Version.set(version) @@ -32,7 +33,7 @@ function M.fixture(root, backend, version) end local files = {} for _, name in ipairs({ "manifest.json", "main.lua" }) do - files["mods/warp/" .. name] = read(root .. "/mods/warp/" .. name) + files["mods/" .. modId .. "/" .. name] = read(root .. "/mods/" .. modId .. "/" .. name) end local fs = { read = function(path) @@ -45,12 +46,12 @@ function M.fixture(root, backend, version) if files[path] then return { type = "file", size = #files[path] } end - if path == "mods" or path == "mods/warp" then + if path == "mods" or path == "mods/" .. modId then return { type = "directory" } end end, getDirectoryItems = function(path) - return path == "mods" and { "warp" } or {} + return path == "mods" and { modId } or {} end, } local loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = version }) @@ -60,7 +61,7 @@ function M.fixture(root, backend, version) return f.live end assert(loader:load(data), table.concat(loader.errors, "; ")) - f.exports = assert(loader.exports.warp) + f.exports = assert(loader.exports[modId]) local save = { party = {}, flags = {}, modData = {} } local player = { cellX = 0, cellY = 0, facing = "left" } local ow = { diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index c3766dea8..0fe96b9ab 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -6,11 +6,19 @@ assert( root and backend and version - and (mode == "normal" or mode == "rollback" or mode == "scaffold" or mode == "warp"), + and ( + mode == "normal" + or mode == "rollback" + or mode == "scaffold" + or mode == "warp" + or mode == "world_edits" + ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "warp" then +if mode == "world_edits" then + dofile(codeRoot .. "/world_edits.lua").run(root, backend, version) +elseif mode == "warp" then dofile(codeRoot .. "/warp.lua").run(root, backend, version) else dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") diff --git a/tests/api3_contract/world_edits.lua b/tests/api3_contract/world_edits.lua new file mode 100644 index 000000000..7d77c555b --- /dev/null +++ b/tests/api3_contract/world_edits.lua @@ -0,0 +1,230 @@ +-- Real loaders, adapters and host commands, with native-shaped map/render seams. +local M = {} +local unpack = table.unpack or unpack +function M.fixture(root, backend, version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/world_edits%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "edits") + local Runtime = require("src.mods.Runtime") + local ow, game = f.ow, f.game + local def = game.data.maps[f.home] + local flagged = + { index = 4, localId = 4, name = "NPC", id = "npc", x = 1, y = 1, eventFlag = "FLAG_A", flag = 7 } + local flagless = { index = 8, localId = 8, name = "TEMP", x = 0, y = 1 } + def.objects = { flagged, flagless } + def.blocks = { 7, 8, 7, 8, 7, 8 } + local tiles = { + walkable = {}, + blocks = { [1] = {}, [8] = {}, [9] = {}, [256] = {} }, + collision = {}, + blockCells = f.gen == 3 and 1 or 2, + blockTiles = f.gen == 3 and 2 or 4, + } + f.flagged, f.flagless, f.visible = flagged, flagless, { NPC = true, TEMP = true } + f.rebuilds, f.syncs, f.reloads = 0, 0, 0 + if backend == "gen1" and f.gen == 2 then + -- Native Gen2 object definitions use list keys; public refs are still 1-based. + flagged.index, flagless.index, flagged.eventFlag = 1, 2, 7 + ow.map = require("src.world.gen2.Map").new(def, tiles) + elseif backend == "gen1" and f.gen == 3 then + -- Native Gen3 uses the reflected layout and field override service. + local overrides = {} + def.midLayout.midAt = function(_, x, y) + return def.blocks[y * def.width + x + 1] + end + f.field.metatileOverrideAt = function(_, x, y) + return overrides[y * 1024 + x] + end + f.field.setMetatile = function(x, y, block) + overrides[y * 1024 + x] = { metatile = block } + f.rebuilds = f.rebuilds + 1 + Runtime.emit("world.block_replaced", { mapId = f.home, bx = x, by = y, block = block }) + end + package.loaded["src.core.game3.objects"] = { + _defs = { flagged, flagless }, + addObject = function(id) + local obj = id == flagged.localId and flagged or flagless + f.visible[obj.name] = true + if obj.flag then + game.save.flags[obj.flag] = false + end + end, + removeObject = function(id) + local obj = id == flagged.localId and flagged or flagless + f.visible[obj.name] = false + if obj.flag then + game.save.flags[obj.flag] = true + end + end, + } + else + ow.map = require("src.world.Map").new(def, tiles) + end + ow.map.renderer = { + rebuild = function() + f.rebuilds = f.rebuilds + 1 + end, + } + function ow:replaceBlock(x, y, block) + self.map:setBlock(x, y, block) + self.map.renderer:rebuild() + self.map.blocksDirty = nil + Runtime.emit("world.block_replaced", { mapId = self.map.id, bx = x, by = y, block = block }) + end + function ow:changeBlock(x, y, block) + self.map.blocks[y * self.map.width + x + 1] = block + self.map.renderer:rebuild() + Runtime.emit("world.block_replaced", { mapId = self.map.id, bx = x, by = y, block = block }) + end + function ow:appearObject(id) + local obj = def.objects[id - 1] + f.visible[obj.name] = true + if obj.eventFlag then + game.save.flags[obj.eventFlag] = false + end + end + function ow:disappearObject(id) + local obj = def.objects[id - 1] + f.visible[obj.name] = false + if obj.eventFlag then + game.save.flags[obj.eventFlag] = true + end + end + function ow:syncObjectVisibility(obj) + f.syncs = f.syncs + 1 + local save = game.save + local session = f.gen == 2 and save.g2ObjectToggles or save.gen3SessionObjects + local key = f.gen == 2 and obj.name or obj.index + local value = session and session.mapId == f.home and session[key] + if obj.eventFlag and save.flags and save.flags[obj.eventFlag] ~= nil then + value = not save.flags[obj.eventFlag] + end + f.visible[obj.name] = value == nil and true or value + end + function ow:setMap(id, x, y, facing, opts) + f.reloads = f.reloads + 1 + assert(id == f.home and x == 0 and y == 0 and facing == "left", "seamless refresh preserves player") + assert(opts.seamless and opts.keepMusic and opts.via == "reload") + for _, obj in ipairs(def.objects) do + local toggles = game.save.objectToggles[id] + if toggles[obj.name] ~= nil then + f.visible[obj.name] = toggles[obj.name] + end + end + end + function f:flagValue() + local key = backend == "gen1" and self.gen > 1 and 7 or "FLAG_A" + return game.save.flags[key] + end + return f +end +function M.run(root, backend, version) + local Json = require("src.link.Json") + local f = M.fixture(root, backend, version) + local report = { invalid = {}, toggles = {}, blocks = {} } + local function unchanged() + assert(#f.exports.events() == 0 and f.rebuilds == 0 and f.reloads == 0 and f.syncs == 0) + assert(next(f.game.save.flags) == nil and f.game.save.objectToggles == nil) + assert(f.visible.NPC and f.visible.TEMP and f.player.cellX == 0 and f.player.cellY == 0) + end + local function refusal(method, args, reason) + local ok, why = f.exports[method](unpack(args, 1, args.n or #args)) + assert(ok == nil and why == reason, tostring(why) .. " ~= " .. reason) + unchanged() + report.invalid[#report.invalid + 1] = why + end + for _, bad in ipairs({ false, "", "bad\nname", string.rep("x", 257) }) do + refusal("toggle", { bad, "NPC", false }, "invalid object toggle") + refusal("toggle", { f.home, bad, false }, "invalid object toggle") + end + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0 }) do + refusal("toggle", { f.home, bad, false }, "invalid object toggle") + end + refusal("toggle", { "UNKNOWN", "NPC", false }, "unknown map: UNKNOWN") + refusal("toggle", { f.home, "NPC", "yes" }, "invalid object toggle") + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0, "7", false, f.gen == 3 and 1024 or 256 }) do + refusal("replace", { 0, 0, bad }, "invalid block id") + end + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0, "0", false }) do + refusal("replace", { bad, 0, 7 }, "invalid block coordinates") + refusal("replace", { 0, bad, 7 }, "invalid block coordinates") + end + refusal("replace", { 2, 0, 7 }, "block coordinates out of bounds") + refusal("replace", { 0, 3, 7 }, "block coordinates out of bounds") + if f.gen ~= 3 then + refusal("replace", { 0, 0, 9 }, "unknown block id") + end + if f.gen > 1 then + refusal("toggle", { f.dest, "NPC", false }, "map is not active: " .. f.dest) + refusal("toggle", { f.home, "UNKNOWN", false }, "no such object: UNKNOWN") + refusal("toggle", { f.home, 0, false }, "no such object: 0") + end + for _, state in ipairs({ "overlay", "moving", "script", "transition" }) do + local other = M.fixture(root, backend, version) + if state == "overlay" then + other.game.stack.states[2] = {} + other.busy = true + elseif state == "moving" then + other.player.moving = true + elseif state == "script" then + other.busy = true + other.ow.runner.isRunning = function() + return true + end + if other.field then + package.loaded["src.core.game3.scripting.space"] = + { vm = { + isRunning = function() + return true + end, + } } + end + else + other.busy, other.ow.transitioning = true, true + if other.field then + other.field.locked = true + end + end + for _, method in ipairs({ "toggle", "replace" }) do + local ok, why + if method == "toggle" then + ok, why = other.exports.toggle(other.home, "NPC", false) + else + ok, why = other.exports.replace(0, 0, 7) + end + assert(ok == nil and type(why) == "string" and #other.exports.events() == 0) + end + end + f = M.fixture(root, backend, version) + for _, visible in ipairs({ false, true, true, false }) do + assert(f.exports.toggle(f.home, "NPC", visible)) + assert(f.visible.NPC == visible and f.visible.TEMP == true) + if f.gen > 1 then + assert(f:flagValue() == not visible) + end + report.toggles[#report.toggles + 1] = { visible = f.visible.NPC, flag = f:flagValue() } + end + -- Numeric refs retain native generation meaning: Gen1 a key, Gen2 list + -- position, Gen3 local id. A portable mod queries the active map definition. + if f.gen > 1 then + assert(f.exports.toggle(f.home, f.gen == 2 and 2 or 8, false)) + assert(not f.visible.TEMP) + assert(f.exports.toggle(f.home, "TEMP", true)) + assert(f.visible.TEMP) + else + assert(f.exports.toggle(f.dest, "LATER", false)) + assert(f.game.save.objectToggles[f.dest].LATER == false) + end + for _, op in ipairs({ { 0, 0, 8 }, { 1, 2, f.gen == 3 and 1023 or 255 }, { 0, 0, 0 } }) do + assert(f.exports.replace(unpack(op))) + local value, metadata = f.exports.read(f.home, op[1], op[2]) + assert(value == op[3] and metadata.coordinateBase == 0 and metadata.tileSize == (f.gen == 3 and 16 or 32)) + report.blocks[#report.blocks + 1] = { x = op[1], y = op[2], block = value, kind = metadata.kind } + end + assert(f.rebuilds == 3 and f.player.cellX == 0 and f.player.cellY == 0) + assert(#f.loader.errors == 0) + report.events = f.exports.events() + report.busy = true + print("API3_CONTRACT " .. Json.encode(report)) + return report +end +return M diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index 4e5ca3a7f..2ce5e14a9 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -135,6 +135,8 @@ do T.eq(type(battle.snapshot), "function", "early battle method callable") for _, name in ipairs({ "warpTo", + "toggleObject", + "replaceBlock", "party", "current", "activeBlockAt", @@ -146,8 +148,6 @@ do T.eq(m:supports("world", name), true, "implemented world method " .. name) end for _, name in ipairs({ - "toggleObject", - "replaceBlock", "spawnNpc", "removeNpc", "npc", @@ -218,6 +218,8 @@ for _, version in ipairs({ "platinum", "black" }) do local m = boot(3, nil, false, version) T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) T.eq(m:supports("world", "warpTo"), false, "no shared warp promised for " .. version) + T.eq(m:supports("world", "toggleObject"), false, "no shared object edits promised for " .. version) + T.eq(m:supports("world", "replaceBlock"), false, "no shared block edits promised for " .. version) local value, why = m.world:party() T.eq(value, nil, "unsupported generation call refuses") T.check(why:find("unsupported:", 1, true), "unsupported generation is explicit") diff --git a/tests/engine/api3_world_edits.lua b/tests/engine/api3_world_edits.lua new file mode 100644 index 000000000..3ef071813 --- /dev/null +++ b/tests/engine/api3_world_edits.lua @@ -0,0 +1,241 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.world_edits") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared object/block contracts: " .. tostring(why)) +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, key in ipairs({ "flags", "objectToggles", "g2ObjectToggles", "gen3Spawned", "gen3SessionObjects" }) do + local gen = Version.generation(version) + if + gen == 1 and (key == "objectToggles" or key == "flags") + or gen == 2 and (key == "flags" or key == "g2ObjectToggles" or key == "objectToggles") + or gen == 3 + and (key == "flags" or key == "gen3Spawned" or key == "gen3SessionObjects" or key == "objectToggles") + then + local f = fixture(version) + f.game.save[key] = "keep" + local ok, why = f.exports.toggle(f.home, "NPC", false) + T.eq(ok, nil, key .. " malformed store rejected") + T.check(type(why) == "string", "store refusal explained") + T.eq(f.game.save[key], "keep", "malformed store retained") + T.eq(#f.exports.events(), 0, "malformed store emits no event") + T.eq(f.visible.NPC, true, "malformed store leaves native object untouched") + end + end +end +for _, version in ipairs({ "red", "firered" }) do + local f = fixture(version) + local key = f.gen == 1 and "objectToggles" or "gen3Spawned" + f.game.save[key] = { [f.home] = "keep" } + T.eq(f.exports.toggle(f.home, "NPC", false), nil, "malformed nested bucket rejected") + T.eq(f.game.save[key][f.home], "keep", "nested bucket never replaced") + T.eq(#f.exports.events(), 0, "no event for malformed nested bucket") +end +for _, lock in ipairs({ + "linkSession", + "linkNet", + "gen3Locked", + "parallelRunners", + "parallelQueue", + "pendingScripts", + "_modScriptLock", +}) do + local f = fixture() + if lock == "linkSession" then + f.game.linkSession = {} + elseif lock == "linkNet" then + f.game.linkNet = { closed = false } + elseif lock == "parallelRunners" or lock == "parallelQueue" or lock == "pendingScripts" then + f.ow[lock] = { {} } + else + f.ow[lock] = true + end + T.eq(f.exports.toggle(f.home, "NPC", false), nil, lock .. " blocks objects") + T.eq(f.exports.replace(0, 0, 7), nil, lock .. " blocks blocks") + T.eq(#f.exports.events(), 0, "busy requests never emit effects") +end +for _, failure in ipairs({ + "renderer", + "coordinates", + "geometry", + "current_block", + "objects", + "object_runtime", +}) do + local f = fixture("gold") + if failure == "renderer" then + f.ow.map.renderer = nil + elseif failure == "coordinates" then + f.ow.map.def.width = 0 + elseif failure == "geometry" then + f.ow.map.blockCells, f.ow.map.blockTiles = 1, 2 + elseif failure == "current_block" then + f.ow.map.def.blocks[1] = nil + elseif failure == "objects" then + f.ow.map.def.objects = nil + else + f.ow.syncObjectVisibility = nil + end + local ok, why + if failure == "objects" or failure == "object_runtime" then + ok, why = f.exports.toggle(f.home, "NPC", false) + else + ok, why = f.exports.replace(0, 0, 7) + end + T.eq(ok, nil, failure .. " refuses safely") + T.check(type(why) == "string", "native context refusal explained") + T.eq(#f.exports.events(), 0, "invalid context emits nothing") + T.eq(f.rebuilds + f.syncs + f.reloads, 0, "invalid context performs no native work") +end +for _, operation in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + if operation == "rollback" then + f.loader:_rollback("edits") + elseif operation == "reload" then + fixture() + else + Loader.teardown() + end + local ok, why = f.exports.toggle(f.home, "NPC", false) + T.eq(ok, nil, operation .. " retires object action") + T.eq(why, "world facade is retired", "retired object diagnostic") + ok, why = f.exports.replace(0, 0, 7) + T.eq(ok, nil, operation .. " retires block action") + T.eq(why, "world facade is retired", "retired block diagnostic") + T.eq(#f.exports.events(), 0, "retired facade emits nothing") +end +do + local f = fixture() + f.exports.reenter(true) + T.check(f.exports.toggle(f.home, "NPC", false), "outer edit accepted") + T.eq(f.exports.nested()[1], nil, "event cannot reenter a partially committed edit") + T.eq(f.exports.nested()[2], "world is busy", "reentry refused explicitly") + T.eq(f.rebuilds, 0, "nested edit never redraws") + T.eq(#f.exports.events(), 1, "outer edit emits once") + f.exports.reenter(false) + T.check(f.exports.replace(0, 0, 8), "guard released after successful edit") + local rebuild = f.ow.map.renderer.rebuild + f.ow.map.renderer.rebuild = function() + error("draw probe", 0) + end + local ok, why = f.exports.replace(0, 0, 7) + T.eq(ok, nil, "native redraw failure contained") + T.check(why:find("draw probe", 1, true), "native failure explained") + f.ow.map.renderer.rebuild = rebuild + T.check(f.exports.replace(0, 0, 8), "guard released after native exception") +end +-- Packed Gen3 source reads remain observational; replacement uses instance patches. +do + local f = fixture("emerald") + f.ow.map.def.blocks = string.rep(string.char(7, 0), 6) + f.ow.map.def._blockArray = nil + f.ow.map.collisionPatch[1] = 1 + f.ow.map.shutCells = { ["0:0"] = true } + T.check(f.exports.replace(0, 0, 8), "packed metatile map writable") + T.eq(f.exports.read(f.home, 0, 0), 8, "reads see metatile patch") + T.eq(f.ow.map.def.blocks, string.rep(string.char(7, 0), 6), "generated source bytes untouched") + T.eq(f.ow.map.def._blockArray, nil, "validation does not decode/cache source") + T.eq(f.ow.map.collisionPatch[1], 0, "native default clears stale collision override") + T.eq(f.ow.map.shutCells["0:0"], nil, "native default clears stale shut cell") +end +-- Native controller methods; no actor construction or graphics are mocked. +love = require("tests.love_stub") +local OW = require("src.world.OverworldController") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.syncObjectVisibility, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.syncObjectVisibility, i, game) + return function() + debug.setupvalue(OW.syncObjectVisibility, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "gold", "silver", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local restore = bind(f.game) + setmetatable(f.ow, { __index = OW }) + f.ow.syncObjectVisibility = OW.syncObjectVisibility + f.ow.replaceBlock = OW.replaceBlock + local function actor(def, x) + return { + id = f.home .. "_obj_" .. def.index, + def = def, + cellX = x, + cellY = 1, + px = x * 16, + py = 16, + facing = "right", + } + end + local target, sibling = actor(f.flagged, 3), actor(f.flagless, 2) + -- Distinct live actors share one persistent flag; only the named actor moves. + f.flagless.eventFlag = f.flagged.eventFlag + f.ow.npcs = { target, sibling } + f.ow.entities = { f.player, target, sibling } + f.ow.npcPool = { [target.id] = target, [sibling.id] = sibling } + T.check(f.exports.toggle(f.home, "NPC", false), version .. " actual native removal") + T.eq(#f.ow.npcs, 1, "only target removed") + T.eq(f.ow.npcs[1], sibling, "shared-flag sibling retained live") + T.eq(sibling.cellX, 2, "unrelated actor not relocated") + T.check(f.exports.toggle(f.home, "NPC", true), "actual native respawn") + T.eq(#f.ow.npcs, 2, "one actor restored") + T.eq(target.cellX, 3, "pooled position retained") + T.eq(target.facing, "right", "pooled facing retained") + f.flagless.eventFlag = nil + T.check(f.exports.toggle(f.home, "TEMP", false), "flagless object hidden for visit") + T.eq(OW.objectVisible(f.game.save, f.home, f.flagless), false, "native spawn predicate sees session hide") + f.game.save.g2ObjectToggles = nil + f.game.save.gen3SessionObjects = nil + T.eq(OW.objectVisible(f.game.save, f.home, f.flagless), true, "flagless default restored after visit reset") + T.check(f.exports.replace(1, 2, 7), "actual native block/redraw path") + T.eq(f.ow.map:blockAt(1, 2), 7, "actual Map returns patched block") + T.eq(f.ow.map.def.blocks[6], 8, "generated map record retained") + T.eq(f.ow.map.blocksDirty, nil, "native redraw consumes dirty flag") + restore() +end +for _, version in ipairs({ "firered", "emerald" }) do + local f = fixture(version) + T.check(f.exports.toggle("HOME", "NPC", false), "unprefixed Gen3 active map resolves") + T.eq(f.exports.events()[1].mapId, f.home, "event reports resolved native map ID") +end +do + local f = fixture("gold") + require("src.script.Gen2Commands") + local Commands = require("src.script.Commands") + Commands.g2ResetObjectBase() + local ctx = { game = f.game, save = f.game.save, overworld = f.ow } + Commands.g2_object(ctx, 2, true) -- Cache the native script numbering first. + f.game.data.field = { objectScriptBase = 1 } + T.check(f.exports.toggle(f.home, 1, false), "public slot resolves independently of cached script IDs") + T.eq(f.visible.NPC, false, "current native template receives visibility effect") + Commands.g2_object(ctx, 1, true) + T.eq(f.visible.NPC, false, "public adapter leaves existing script-ID cache unchanged") + Commands.g2ResetObjectBase() +end +-- Existing native/API-2 facade retains its own coercion/validation behavior. +do + local f = fixture() + local api = require("src.world.WorldAPI").new(f.game, "legacy") + T.check(api:toggleObject("UNKNOWN", "missing", "truthy"), "legacy off-map toggle unchanged") + T.eq(f.game.save.objectToggles.UNKNOWN.missing, true, "legacy boolean coercion unchanged") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 object and block edits") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 52ef00c31..b20738ec1 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From f18425648ef6bbc17b67b7ed46ad514f381a53a1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:36:15 +0200 Subject: [PATCH 15/71] Add API 3 public NPC actions with cross-engine contracts --- docs/api3-npc.md | 142 +++++ docs/api3-portability.md | 13 +- docs/api3-warp.md | 2 +- docs/api3-world-edits.md | 2 +- docs/modding.md | 3 +- src/mods/Api3.lua | 11 +- src/mods/Api3NPC.lua | 565 ++++++++++++++++++++ src/mods/Api3WorldEdits.lua | 25 +- src/mods/Loader.lua | 4 + tests/api3_contract/mods/npcs/main.lua | 28 + tests/api3_contract/mods/npcs/manifest.json | 1 + tests/api3_contract/npcs.lua | 320 +++++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/api3_compat.lua | 9 +- tests/engine/api3_npc.lua | 423 +++++++++++++++ tools/api3-contract-check/check.py | 2 +- 16 files changed, 1533 insertions(+), 22 deletions(-) create mode 100644 docs/api3-npc.md create mode 100644 src/mods/Api3NPC.lua create mode 100644 tests/api3_contract/mods/npcs/main.lua create mode 100644 tests/api3_contract/mods/npcs/manifest.json create mode 100644 tests/api3_contract/npcs.lua create mode 100644 tests/engine/api3_npc.lua diff --git a/docs/api3-npc.md b/docs/api3-npc.md new file mode 100644 index 000000000..3a38e387b --- /dev/null +++ b/docs/api3-npc.md @@ -0,0 +1,142 @@ +# API-3 public NPC actions + +Generations 1–3 support the same public creation, lookup and removal calls as +Gen1Recomp. Check availability and retain the world facade at mod entry: + +```lua +local mod = ... +local world = mod.world + +local function greet(mapId, spriteId) + if not mod:supports("world", "spawnNpc") then return end + local id, why = world:spawnNpc(mapId, { + name = "GREETER", sprite = spriteId, x = 3, y = 4, facing = "down", + }) + if not id then return nil, why end + local npc, reason = world:npc(mapId, id) + if not npc then return nil, reason end + return npc:scriptMove("down", 2, function() + npc:face("left") + end) +end +``` + +Invoke this later from an idle world. Reading the facade never bootstraps Game. +Gen4/5 report these three calls unavailable. API-1/2 retain their native behavior; +this does not add portable `queueScript` verbs. + +## Creation, lookup and ownership + +- `spawnNpc(mapId, definition)` returns the native runtime ID or `nil, reason`. + It can add a runtime definition to a known inactive map; an actor appears when + the native map lifecycle instantiates it. The return value promises a recorded + definition, not immediate live lookup or a common lifecycle event order. +- `npc(mapId, reference)` returns an opaque handle or `nil, reason`. References + are the live actor's string ID, its definition's numeric index or its name. + Only the active map and currently live neighboring map actors can be queried. +- `removeNpc(id)` returns `true` or `nil, reason`. Only runtime objects owned by + the calling mod may be removed. Imported objects and other owners are refused. + A mod may still look up and control an imported live actor. + +Map, sprite and object identifiers remain dataset-specific. Gen3 recognizes the +current version's `FR_`, `EM_`, `RU_` or `SA_` map prefix when an exact map ID is +absent. IDs/names are nonempty strings of at most 256 bytes without control +characters; numeric references are finite nonnegative integers. Use the ID +returned by `spawnNpc` for removal. + +Definitions are copied before native construction. Required `x` and `y` are +whole nonnegative walk-cell coordinates, at most 1,000,000. Optional `facing` is +`up`, `down`, `left` or `right`; optional `name` and `sprite` are identifiers. +A supplied sprite must exist in the active sprite registry. Native generation +fields remain supported as data; this API does not invent a canonical sprite, +movement or NPC definition schema. The host assigns runtime ownership and +identity, ignoring caller-supplied ownership/index markers. + +The copy accepts plain acyclic tables, strings, finite numbers and booleans. +Functions, userdata, metatables and cycles are refused before native writes. +Limits are depth 16, 4096 visited values/keys, 256 entries per table, 4096 bytes +per string and 64 KiB total string bytes. Refusal returns `nil, reason`. + +## Handles + +Handles expose `id` and these methods; they never expose the native actor: + +| Method | Result and behavior | +| --- | --- | +| `position()` | `x, y` in zero-based 16-pixel walk cells, in the actor's own map frame. | +| `isMoving()` | Boolean native movement state. | +| `canStep(direction)` | Boolean native collision permission, including the host's current seam cast for connected maps. It is a query, not a reservation. | +| `face(direction)` | Set facing. | +| `stepNow(direction)` | Begin one authored step; refuse an already moving actor. | +| `placeAt(x, y, facing?)` | Place at whole nonnegative coordinates up to 1,000,000, clear in-flight movement and synchronize pixel position. | +| `setPassable(boolean)` | Set the live actor's passability. | +| `setAppearance(spriteId)` | Refresh a live actor using an existing sprite registry entry. | +| `scriptMove(direction, tiles?, onDone?)` | Queue native movement, default one cell; `tiles` must be an integer from 1 to 1024. | +| `marchInPlace(onDone?)` | Queue one native stationary march. | + +Directions are the four lowercase names above. Mutations return `true` or +`nil, reason`; a stale handle returns `nil, "NPC handle is stale"`. Handles are +bound to the original actor, map instance, Game, save, data and version. Reused +native IDs or editing the public `id` field cannot redirect a retained handle. +The facade follows the current Game, while old actor handles do not. + +Spawn, removal and handle mutations use the shared API-3 idle-world guard: +a save and topmost overworld are required; menus, native/portable/parallel +scripts, player movement, controller locks, transitions and link sessions refuse +admission. Reads remain usable during overlays. Synchronous reentry into NPC or +object/block mutations is refused. Movement tracks require the active map; +other handle calls also work for live neighboring actors. + +`stepNow` and `placeAt` are authored operations, matching the native distinction +from `canStep`: they do not automatically perform collision validation or clamp +to map bounds. Choose coordinates and query collision as appropriate for the +scene. Appearance changes construct a renderer before committing the actor +change, retain its cell position and leave the native template sprite intact; +they are not persistent content edits. + +## Movement and mod lifetime + +Completion callbacks are optional, one-shot and attributed to the initiating +mod. Native synchronous completion is deferred until admission finishes and +the shared mutation guard is released. Native callback arguments, including +nil holes, are preserved. Callback errors are contained and reported once. + +Pending completions are permanently retired on observed Game/save/data/world, +map, stack, version, actor or runtime-bus replacement, and on native map lifecycle +events. The input-step boundary observes replacements before and after native +updates. Retiring removes only the movement entries owned by that operation; +it does not pop screens or cancel another mod's tracks. + +Rollback, reload and teardown retire the mod's facade and pending tracks and +remove its recorded runtime definitions/actors, including definitions spawned +on inactive maps and instantiated later as neighbors. Foreign runtime objects +and imported actors remain. Controls already applied to imported actors are +ordinary native scene changes, not a transactional patch with automatic undo. +Retained handles/facades return `nil, "world facade is retired"` after retirement. + +## Verification + +```sh +luajit tests/engine/api3_npc.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The clean Gen1 reference stays pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same public-only NPC mod runs +through both production loaders for Red, Blue, Yellow, Gold, Silver, Crystal, +FireRed and Emerald. Its eight cases extend the full checker to 48 comparisons +and 96 isolated runs. The shared fixture compares creation/lookup, detached +definitions, collision/passability, appearance, authored/queued movement, +callback replay/arguments, optional callbacks and ownership-aware removal. + +Local tests additionally cover bounded validation, busy worlds, reentry, stale +sessions/handles, callback errors, lifecycle cancellation and scoped cleanup. +They exercise the host's actual NPC constructor, native runtime add/remove, +scriptMove/marchInPlace/updateScriptMoves, NPC updates and seam collision casts. +Synthetic datasets and a deterministic SpriteRenderer construction seam keep +these ROM-free. The tests verify the documented shared subset, not retail-ROM +playthroughs, GPU artwork or identical native internals/event timing. Host +validation and lifecycle guards are intentionally stricter than some reference +native paths. diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 0c7b584ed..5065c4c1a 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -79,12 +79,13 @@ The common world calls currently enabled for generations 1–3 are: - `queueScript` for the existing bounded portable script language; - [`warpTo`](api3-warp.md) for validated native map transitions; - [`toggleObject` and `replaceBlock`](api3-world-edits.md) for validated native - visibility and active block/metatile edits. + visibility and active block/metatile edits; +- [`spawnNpc`, `removeNpc` and `npc`](api3-npc.md) for runtime NPC creation, + ownership-aware removal and opaque live actor controls. Check `mod:supports("world", name)` before relying on a call. The other Gen1 world method names remain callable but explicitly return -`nil, "unsupported: ..."`: runtime NPC operations, -`startWildBattle` and `invalidateMap` have not yet been verified against the +`nil, "unsupported: ..."`: `startWildBattle` and `invalidateMap` have not yet been verified against the Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. Gen4/5 do not claim the shared world surface in this first entry PR. @@ -131,8 +132,8 @@ The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. -Eight object/block cases extend this to 40 comparisons and 80 isolated loader -runs. It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 48 comparisons +and 96 isolated loader runs. It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge @@ -144,6 +145,8 @@ arguments, destination events, boundary coordinates, arrival options, callback argument holes/replay, stale sessions/owners, synchronous completion and error attribution. Object/block cases compare native reference routing, visibility/flag changes, block geometry/bounds, native reads and one event per successful edit. +NPC cases compare runtime creation/lookup, movement/appearance, optional +completion callbacks and ownership-aware removal using the same public handles. Separate Gen2 tests check API-1/2 behavior, contradictory manifest fields, honest availability and malformed flag stores. CI pins the same external commit and uploads the report. diff --git a/docs/api3-warp.md b/docs/api3-warp.md index dbb19a6a8..6e4c1f055 100644 --- a/docs/api3-warp.md +++ b/docs/api3-warp.md @@ -119,7 +119,7 @@ and replaces only native transition timing seams. It compares validation reasons, dispatch arguments, public destination events and callback results. The mod has no engine imports or backend-specific branches. Together with the existing normal, rollback, generated-starter and object/block cases, CI now -performs 40 comparisons / 80 isolated loader runs. +performs 48 comparisons / 96 isolated loader runs. The local suite additionally drives Gen2Recomped's real native `startWarpTo` and `Transition` using synthetic map materialization, including diff --git a/docs/api3-world-edits.md b/docs/api3-world-edits.md index 4297c602f..1e66a6fa1 100644 --- a/docs/api3-world-edits.md +++ b/docs/api3-world-edits.md @@ -112,7 +112,7 @@ runs under both production loaders for Red, Blue, Yellow, Gold, Silver, Crystal, FireRed and Emerald. Engine-specific setup stays in the shared driver, never in the mod. Native-shaped map/render/object seams let the driver compare refusal reasons, visibility/flag effects, native block reads, geometry -and event payloads. The full checker now runs 40 comparisons / 80 isolated +and event payloads. The full checker now runs 48 comparisons / 96 isolated processes, including all previous entry, rollback, generated-starter and warp cases. diff --git a/docs/modding.md b/docs/modding.md index 153ac03e7..d6169c421 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -24,7 +24,8 @@ The [API-3 entry and cross-engine contracts](api3-portability.md) describe using the same manifest and mod source on Gen1Recomp and Gen2Recomped. API 3 selects the unified adapters automatically; API 1/2 keep their defaults. See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime, -and [API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes. +[API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes, +and [API-3 NPC actions](api3-npc.md) for runtime actors and movement handles. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 29d7f0139..8e678aca5 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -4,6 +4,7 @@ local Records = require("src.mods.Api3Records") local Runtime = require("src.mods.Runtime") local Warp = require("src.mods.Api3Warp") local Edits = require("src.mods.Api3WorldEdits") +local NPC = require("src.mods.Api3NPC") local copy = require("src.mods.Merge").deepCopy local A = {} local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty @@ -14,7 +15,9 @@ local SUPPORTED = {} for name in ([[overworld current activeBlockAt party canReorderParty reorderParty availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters - setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock]]):gmatch("%S+") + setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc]]):gmatch( + "%S+" + ) do SUPPORTED[name] = true end @@ -80,14 +83,18 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) end return nativeWorld end - local warp, licensed + local warp, licensed, npc if supportedGeneration then warp, licensed = Warp.new(loader, api.id, gen, current, native) + npc = NPC.new(loader, api.id, gen, current, native, licensed, engineRequire) end local function dispatch(name, ...) if not supportedGeneration or not SUPPORTED[name] then return nil, "unsupported: API 3 world method " .. name end + if name == "spawnNpc" or name == "removeNpc" or name == "npc" then + return npc(name, ...) + end if name == "warpTo" then return warp(...) end diff --git a/src/mods/Api3NPC.lua b/src/mods/Api3NPC.lua new file mode 100644 index 000000000..8a4091f2a --- /dev/null +++ b/src/mods/Api3NPC.lua @@ -0,0 +1,565 @@ +-- Public API-3 NPC handles. Engine objects never cross the handle boundary. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Edits = require("src.mods.Api3WorldEdits") +local Collision = require("src.world.Collision") +local copy = require("src.mods.Merge").deepCopy +local N = {} +local services = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local moves = {} +local dirs = { up = true, down = true, left = true, right = true } +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function integer(n, minimum, maximum) + return type(n) == "number" + and n == n + and n >= (minimum or 0) + and n < math.huge + and n % 1 == 0 + and (not maximum or n <= maximum) +end +local function message(value) + local ok, text = pcall(tostring, value) + return ok and text or "unprintable error" +end +local function identifier(s) + return type(s) == "string" and #s > 0 and #s <= 256 and not s:find("%c") +end +local function reference(v) + return identifier(v) or integer(v) +end +-- Bounded data-only copy, including extra generation-native definition fields. +local function definition(value) + local visiting, nodes, bytes = {}, 0, 0 + local function clone(v, depth) + nodes = nodes + 1 + if nodes > 4096 or depth > 16 then + error("NPC definition too large", 0) + end + local t = type(v) + if t == "string" then + bytes = bytes + #v + if #v > 4096 or bytes > 65536 then + error("NPC definition too large", 0) + end + return v + elseif t == "number" then + if v ~= v or v == math.huge or v == -math.huge then + error("invalid NPC definition", 0) + end + return v + elseif t == "nil" or t == "boolean" then + return v + elseif t ~= "table" or getmetatable(v) ~= nil or visiting[v] then + error("invalid NPC definition", 0) + end + visiting[v] = true + local out, count = {}, 0 + for k, item in pairs(v) do + count = count + 1 + if count > 256 or type(k) ~= "string" and not integer(k) then + error("invalid NPC definition", 0) + end + out[clone(k, depth + 1)] = clone(item, depth + 1) + end + visiting[v] = nil + return out + end + if type(value) ~= "table" then + return nil, "invalid NPC definition" + end + local ok, result = pcall(clone, value, 0) + if not ok then + return nil, result + end + if not integer(result.x, 0, 1000000) or not integer(result.y, 0, 1000000) then + return nil, "invalid NPC coordinates" + end + for _, key in ipairs({ "name", "sprite" }) do + if result[key] ~= nil and not identifier(result[key]) then + return nil, "invalid NPC " .. key + end + end + if result.facing ~= nil and not dirs[result.facing] then + return nil, "invalid NPC facing" + end + return result +end +local function removeFrom(list, predicate) + for i = #(list or {}), 1, -1 do + if predicate(list[i]) then + table.remove(list, i) + end + end +end +local function retire(move) + move.done = true + moves[move] = nil + move.callback = nil + local removed = false + removeFrom(move.world.scriptMoves, function(entry) + if move.entries[entry] then + removed = true + return true + end + end) + if removed then + local foreign = false + for _, entry in ipairs(move.world.scriptMoves or {}) do + if entry.entity == move.actor then + foreign = true + end + end + if not foreign then + move.actor.moving, move.actor.marching = false, false + move.actor.targetX, move.actor.targetY = nil, nil + move.actor.px, move.actor.py = move.actor.cellX * 16, move.actor.cellY * 16 + end + end +end +local function removeSpawn(token) + -- Scoped by exact native record identity, even after Game/provider changes. + local rows = token.data.maps and token.data.maps[token.mapId] + local removed = false + removeFrom(rows and rows.objects, function(row) + if row == token.record then + removed = true + return true + end + end) + local function owned(actor) + return actor + and ( + actor == token.actor + or actor.def == token.record + or removed and actor.id == token.mapId .. "_obj_" .. tostring(token.record.index) + ) + end + removeFrom(token.world.npcs, owned) + removeFrom(token.world.entities, owned) + removeFrom(token.world.ghosts, function(g) + return owned(g.npc) + end) + for id, actor in pairs(token.world.npcPool or {}) do + if owned(actor) then + token.world.npcPool[id] = nil + end + end +end +function N.cancelAll(loader, owner) + for service in pairs(services) do + if (not loader or loader == service.loader) and (not owner or owner == service.owner) then + service.closed = true + for move in pairs(moves) do + if move.service == service then + retire(move) + end + end + for _, token in ipairs(service.spawned) do + removeSpawn(token) + end + service.spawned = {} + services[service] = nil + end + end +end +local function inspect(loader) + for move in pairs(moves) do + if move.service.loader == loader then + local ok, valid = pcall(move.valid) + if not ok or not valid then + retire(move) + end + end + end +end +local function observe(loader) + if observers[loader] and observers[loader][1] == loader.hooks then + return + end + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + loader.hooks:wrap("input.step", function(nextFn, ...) + if next(moves) == nil then + return nextFn(...) + end + inspect(loader) + local result = pack(pcall(nextFn, ...)) + inspect(loader) + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, math.huge, "engine:api3_npc") + for _, event in ipairs({ "map.exited", "map.entered" }) do + loader.events:on(event, function() + for move in pairs(moves) do + if move.service.loader == loader then + retire(move) + end + end + end, math.huge, "engine:api3_npc") + end +end +function N.new(loader, owner, gen, current, native, licensed, req) + local service = { loader = loader, owner = owner, spawned = {} } + services[service] = true + observe(loader) + local function alive() + return not service.closed and licensed() + end + local function resolveGame() + if not alive() then + return nil, nil, "world facade is retired" + end + local game = current() + if not game then + return nil, nil, "no game" + end + local ow = native(game):overworld() + if not ow then + return nil, nil, "no overworld" + end + return game, ow + end + local function mapId(game, id) + if gen == 3 and not (game.data.maps or {})[id] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if (game.data.maps or {})[prefix .. id] then + return prefix .. id + end + end + return id + end + local function actorAt(ow, id, ref) + local function matches(actor) + local def = actor.def or {} + return actor.id == ref or def.index == ref or def.name == ref + end + if ow.map and ow.map.id == id then + for _, actor in ipairs(ow.npcs or {}) do + if matches(actor) then + return actor, ow.map, ow.cast or ow.entities + end + end + return nil, "no such object: " .. tostring(ref) + end + for _, g in ipairs(ow.ghosts or {}) do + local actor = g.npc + local map = g.map + if map and map.id == id and actor and matches(actor) then + local peers = g.nb and g.nb.cast or g.peers + if not peers then + peers = {} + for _, sibling in ipairs(ow.ghosts) do + if sibling.map == map then + peers[#peers + 1] = sibling.npc + end + end + end + return actor, map, peers, true + end + end + return nil, "map is not live" + end + local function sprite(game, id) + return game.data and game.data.sprites and game.data.sprites[id] + end + local function queue(game, ow, actor, valid, name, a, b, callback) + local hooks, events = Runtime.hooks, Runtime.events + local move = { service = service, world = ow, actor = actor, entries = {}, callback = callback } + local save, data, stack, map, version, fastWorld = + game.save, game.data, game.stack, ow.map, Version.get(), game.overworld + function move.valid() + return alive() + and current() == game + and game.save == save + and game.data == data + and game.stack == stack + and game.overworld == fastWorld + and ow.map == map + and Version.get() == version + and Runtime.hooks == hooks + and Runtime.events == events + and valid() + end + local accepted, deferred + local function finish(...) + if move.done then + return + end + if accepted == nil then + if not deferred then + deferred = pack(...) + end + return + end + local checked, valid = pcall(move.valid) + local fn = accepted and checked and valid and move.callback + retire(move) + if not fn then + return + end + local previous = Runtime.currentMod + Runtime.currentMod = owner + local ok, why = pcall(fn, ...) + Runtime.currentMod = previous + if not ok then + Runtime.reportError(owner, "NPC movement callback failed: " .. message(why)) + end + end + local before = {} + for _, entry in ipairs(ow.scriptMoves or {}) do + before[entry] = true + end + moves[move] = true + local called, result, detail + if name == "scriptMove" then + called, result, detail = pcall(ow.scriptMove, ow, actor, a, b or 1, finish) + else + called, result, detail = pcall(ow.marchInPlace, ow, actor, finish) + end + for _, entry in ipairs(ow.scriptMoves or {}) do + if not before[entry] and entry.entity == actor then + move.entries[entry] = true + end + end + accepted = called and result ~= false and not (result == nil and detail ~= nil) + if accepted then + local checked, valid = pcall(move.valid) + accepted = checked and valid and not move.done + end + if not accepted then + retire(move) + return nil, + called and (detail or "NPC movement failed") or "world operation failed: " .. message(result) + end + local drain = deferred and function() + finish(unpack(deferred, 1, deferred.n)) + end + return true, nil, drain + end + local function handle(game, ow, id, ref) + local actor, map = actorAt(ow, id, ref) + if not actor then + return nil, map + end + local publicId = actor.id + if not identifier(publicId) then + return nil, "NPC identity unavailable" + end + local save, data, version, fastWorld = game.save, game.data, Version.get(), game.overworld + local function live() + if + not alive() + or current() ~= game + or game.save ~= save + or game.data ~= data + or game.overworld ~= fastWorld + or Version.get() ~= version + or native(game):overworld() ~= ow + then + return + end + local found, activeMap, peers, ghost = actorAt(ow, id, publicId) + if found ~= actor or activeMap ~= map then + return + end + return actor, activeMap, peers, ghost + end + local out = { id = publicId } + local function invoke(name, a, b, c) + if not alive() then + return nil, "world facade is retired" + end + local npc, onMap, peers, ghost = live() + if not npc then + return nil, "NPC handle is stale" + end + if name == "position" then + return npc.cellX, npc.cellY + end + if name == "isMoving" then + return not not npc.moving + end + if name == "canStep" or name == "stepNow" or name == "face" or name == "scriptMove" then + if not dirs[a] then + return nil, "invalid direction" + end + end + if name == "canStep" then + return not not Collision.canMove(onMap, peers or {}, npc, a) + end + if + name == "scriptMove" and (b ~= nil and not integer(b, 1, 1024) or c ~= nil and type(c) ~= "function") + then + return nil, "invalid movement" + end + if name == "marchInPlace" and a ~= nil and type(a) ~= "function" then + return nil, "invalid callback" + end + if + name == "placeAt" + and (not integer(a, 0, 1000000) or not integer(b, 0, 1000000) or c ~= nil and not dirs[c]) + then + return nil, "invalid coordinates" + end + if name == "setPassable" and type(a) ~= "boolean" then + return nil, "invalid passability" + end + if name == "setAppearance" and not identifier(a) then + return nil, "invalid sprite id" + end + local ok, why = Edits.ready(game, ow) + if not ok then + return nil, why + end + if ghost and (name == "scriptMove" or name == "marchInPlace") then + return nil, "movement tracks require the active map" + end + local drain + local value, detail = Edits.run(game, function() + if name == "scriptMove" or name == "marchInPlace" then + local value, why + local callback = a + if name == "scriptMove" then + callback = c + end + value, why, drain = queue(game, ow, npc, live, name, a, b, callback) + return value, why + elseif name == "face" then + npc.facing = a + elseif name == "stepNow" then + if npc.moving then + return nil, "already moving" + end + npc.facing = a + npc.targetX, npc.targetY = Collision.target(npc.cellX, npc.cellY, a) + npc.moving, npc.progress = true, 0 + elseif name == "placeAt" then + npc.moving, npc.marching, npc.progress = false, false, 0 + npc.targetX, npc.targetY = nil, nil + npc.cellX, npc.cellY, npc.px, npc.py = a, b, a * 16, b * 16 + if c then + npc.facing = c + end + elseif name == "setPassable" then + npc.passable = a + elseif name == "setAppearance" then + if not sprite(game, a) then + return nil, "unknown sprite: " .. a + end + local proxy = { id = npc.id, def = copy(npc.def) } + proxy.def.sprite = a + proxy.def.graphicsId, proxy.def.graphics, proxy.def.graphicsVar, proxy.def.varSprite = + nil, nil, nil, nil + req("src.world.NPC").refreshSprite(proxy, game.data) + npc.def, npc.sprite, npc.appearance = proxy.def, proxy.sprite, a + npc.big = proxy.sprite and proxy.sprite.big or false + end + return true + end) + if drain then + drain() + end + return value, detail + end + for _, name in ipairs({ + "position", + "isMoving", + "canStep", + "scriptMove", + "marchInPlace", + "face", + "stepNow", + "placeAt", + "setPassable", + "setAppearance", + }) do + local method = name + out[method] = function(_, ...) + local previous = Runtime.currentMod + Runtime.currentMod = nil + local result = pack(pcall(invoke, method, ...)) + Runtime.currentMod = previous + if not result[1] then + return nil, "world operation failed: " .. message(result[2]) + end + return unpack(result, 2, result.n) + end + end + return out + end + return function(name, a, b) + local game, ow, why = resolveGame() + if not game then + return nil, why + end + if name == "npc" then + if not identifier(a) or not reference(b) then + return nil, "invalid NPC reference" + end + return handle(game, ow, mapId(game, a), b) + end + local ok, reason = Edits.ready(game, ow) + if not ok then + return nil, reason + end + return Edits.run(game, function() + if name == "spawnNpc" then + if not identifier(a) or type(b) ~= "table" then + return nil, "invalid NPC definition" + end + local id = mapId(game, a) + local mapDef = game.data.maps and game.data.maps[id] + if not mapDef then + return nil, "unknown map: " .. a + end + local row, err = definition(b) + if not row then + return nil, err + end + if row.sprite and not sprite(game, row.sprite) then + return nil, "unknown sprite: " .. row.sprite + end + local before = {} + for _, item in ipairs(mapDef.objects or {}) do + before[item] = true + end + local called, value, detail = pcall(native(game).spawnNpc, native(game), id, row) + for _, item in ipairs(mapDef.objects or {}) do + if not before[item] and item.runtime and item.owner == owner then + local publicId = id .. "_obj_" .. tostring(item.index) + local actor = actorAt(ow, id, publicId) + local token = { data = game.data, world = ow, mapId = id, record = item, actor = actor } + if not called or not value then + removeSpawn(token) + else + service.spawned[#service.spawned + 1] = token + end + end + end + if not called then + return nil, "world operation failed: " .. message(value) + end + return value, detail + end + if not reference(a) or gen == 3 and not identifier(a) then + return nil, "invalid NPC id" + end + local value, err = native(game):removeNpc(a) + if value then + for i = #service.spawned, 1, -1 do + local token = service.spawned[i] + if token.mapId .. "_obj_" .. tostring(token.record.index) == a then + table.remove(service.spawned, i) + end + end + end + return value, err + end) + end +end +return N diff --git a/src/mods/Api3WorldEdits.lua b/src/mods/Api3WorldEdits.lua index 522adcb04..c21586e25 100644 --- a/src/mods/Api3WorldEdits.lua +++ b/src/mods/Api3WorldEdits.lua @@ -130,11 +130,8 @@ local function replace(game, ow, api, gen, req, bx, by, block) Runtime.emit("world.block_replaced", { mapId = ow.map.id, bx = bx, by = by, block = block }) return true end -function E.call(game, api, generation, req, name, ...) - local ow = api:overworld() - if not ow then - return nil, "no overworld" - end +-- Shared admission/commit guard for synchronous API-3 world mutations. +function E.ready(game, ow) local ok, reason = Actions.ready(game, ow) if not ok then return nil, reason @@ -142,13 +139,27 @@ function E.call(game, api, generation, req, name, ...) if running[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then return nil, "world is busy" end + return true +end +function E.run(game, operation, ...) running[game] = true - local operation = name == "toggleObject" and toggle or replace - local called, value, why = pcall(operation, game, ow, api, generation, req, ...) + local called, value, why = pcall(operation, ...) running[game] = nil if not called then error(value, 0) end return value, why end +function E.call(game, api, generation, req, name, ...) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local ok, reason = E.ready(game, ow) + if not ok then + return nil, reason + end + local operation = name == "toggleObject" and toggle or replace + return E.run(game, operation, game, ow, api, generation, req, ...) +end return E diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 17ecd49e0..c24867e06 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -13,6 +13,7 @@ local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") local PortableScripts = require("src.mods.PortableScripts") local Api3Warp = require("src.mods.Api3Warp") +local Api3NPC = require("src.mods.Api3NPC") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") @@ -1263,6 +1264,7 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + Api3NPC.cancelAll(self, modId) Api3Warp.cancelAll(self, modId) PortableScripts.cancelAll(self, modId, "mod rolled back") for name in pairs(self.journal or self.content) do @@ -1379,6 +1381,7 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + Api3NPC.cancelAll() Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods reloaded") -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST @@ -1724,6 +1727,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + Api3NPC.cancelAll() Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods unloaded") local undone = select(1, ModSeams.unwindAll()) diff --git a/tests/api3_contract/mods/npcs/main.lua b/tests/api3_contract/mods/npcs/main.lua new file mode 100644 index 000000000..696dfaafe --- /dev/null +++ b/tests/api3_contract/mods/npcs/main.lua @@ -0,0 +1,28 @@ +-- Same public-only mod on both engines. Native timing stays in the driver. +local mod = ... +local world = mod.world +for _, name in ipairs({ "spawnNpc", "removeNpc", "npc" }) do + assert(mod:supports("world", name)) +end +local ok, why = world:npc("HOME", 1) +assert(ok == nil and why == "no game") +local calls, args = 0, nil +mod.exports.spawn = function(...) + return world:spawnNpc(...) +end +mod.exports.remove = function(...) + return world:removeNpc(...) +end +mod.exports.npc = function(...) + return world:npc(...) +end +mod.exports.toggle = function(...) + return world:toggleObject(...) +end +mod.exports.done = function(...) + calls = calls + 1 + args = { n = select("#", ...), first = (...), third = select(3, ...) } +end +mod.exports.result = function() + return { calls = calls, args = args } +end diff --git a/tests/api3_contract/mods/npcs/manifest.json b/tests/api3_contract/mods/npcs/manifest.json new file mode 100644 index 000000000..16a8a3457 --- /dev/null +++ b/tests/api3_contract/mods/npcs/manifest.json @@ -0,0 +1 @@ +{"id":"npcs","name":"Portable NPC Contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/npcs.lua b/tests/api3_contract/npcs.lua new file mode 100644 index 000000000..7ee2a243f --- /dev/null +++ b/tests/api3_contract/npcs.lua @@ -0,0 +1,320 @@ +-- Real loaders/native public adapters with native actor/map/timing seams. +local M = {} +local unpack = table.unpack or unpack +function M.fixture(root, backend, version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/npcs%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "npcs") + local Runtime = require("src.mods.Runtime") + love = love or require("tests.love_stub") + -- Render construction has no bearing on the public state contract. + local Renderer = require("src.render.SpriteRenderer") + local savedRenderer = Renderer.new + Renderer.new = function(def) + return { def = def, big = false } + end + f.restoreRenderer = function() + Renderer.new = savedRenderer + end + local game, ow = f.game, f.ow + game.data.sprites = { + PROBE = { image = "fixture.png", frames = 6, walker = true }, + ALT = { image = "alt.png", frames = 6, walker = true }, + } + game.data.gen2Sprites = game.data.sprites + ow.game, ow.sprites = game, game.data.sprites + local tiles = { + blocks = { [2] = {} }, + walkable = { 0 }, + collision = { [2] = { 0, 0, 0, 0 } }, + blockCells = f.gen == 3 and 1 or 2, + blockTiles = f.gen == 3 and 2 or 4, + } + for i = 1, 16 do + tiles.blocks[2][i] = 0 + end + if not (backend == "gen1" and f.gen == 2) then + tiles.collision = nil + end + for _, id in ipairs({ f.home, f.dest }) do + local def = game.data.maps[id] + def.width, def.height, def.objects, def.blocks = 8, 8, {}, {} + for i = 1, 64 do + def.blocks[i] = 1 + end + end + local base = { + index = 1, + localId = 1, + name = "IMPORTED", + sprite = "PROBE", + x = 6, + y = 6, + movement = "STAY", + range = "DOWN", + } + game.data.maps[f.home].objects = { base } + if backend == "gen1" and f.gen == 2 then + ow.map = require("src.world.gen2.Map").new(game.data.maps[f.home], tiles) + elseif not (backend == "gen1" and f.gen == 3) then + ow.map = require("src.world.Map").new(game.data.maps[f.home], tiles) + end + ow.npcs, ow.entities, ow.npcPool = {}, { f.player }, {} + f.player.cellX, f.player.cellY = 7, 7 + function f:make(id, row) + local actor + if backend == "gen1" and self.gen == 2 then + actor = require("src.world.gen2.Npc").new(id, row, game.data.sprites[row.sprite]) + elseif backend == "gen1" and self.gen == 3 then + actor = require("src.core.game3.object_prepare").instance(row, { mapId = id }) + actor.id, actor.publicId = id .. "_obj_" .. row.index, id .. "_obj_" .. row.index + else + actor = require("src.world.NPC").new(game.data, id, row) + end + function actor:queueApiMoves(_, callback) + f:begin(self, "march", nil, 1, callback) + return true + end + return actor + end + f.imported = f:make(f.home, base) + ow.npcs[1] = f.imported + ow.entities[2] = f.imported + ow.npcPool[f.imported.id] = f.imported + function f:begin(actor, kind, dir, count, callback) + self.pending = { actor = actor, kind = kind, dir = dir, count = count, callback = callback } + self.busy = true + actor.moving = true + if backend == "gen2" or self.gen == 1 then + local entry = + { entity = actor, dir = dir, remaining = count, onDone = callback, inPlace = kind == "march" } + ow.scriptMoves[#ow.scriptMoves + 1] = entry + end + if self.synchronous then + self:complete("sync", nil, "tail") + end + if self.failMovement then + return false, "movement refused" + end + return true + end + function ow:scriptMove(actor, dir, count, callback) + return f:begin(actor, "move", dir, count, callback) + end + function ow:marchInPlace(actor, callback) + return f:begin(actor, "march", nil, 1, callback) + end + function ow:beginMovement(objectId, bytes, callback) + local actor + for _, candidate in ipairs(self.npcs) do + if candidate.def.index + 1 == objectId then + actor = candidate + end + end + local dir = ({ [0x0C] = "down", [0x0D] = "up", [0x0E] = "left", [0x0F] = "right" })[bytes[1]] + return f:begin(assert(actor), "move", assert(dir), #bytes - 1, callback) + end + function ow:applySpritePalette(actor) + f.paletteActor = actor + end + function f:complete(...) + local pending = self.pending + assert(pending, "no pending move") + local actor = pending.actor + if pending.kind == "move" then + local delta = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[pending.dir] + actor.cellX, actor.cellY = + actor.cellX + delta[1] * pending.count, actor.cellY + delta[2] * pending.count + end + actor.moving, actor.marching = false, false + actor.px, actor.py = actor.cellX * 16, actor.cellY * 16 + self.busy = false + ow.scriptMoves = {} + if pending.callback then + pending.callback(...) + end + end + function f:spawn(id, row, owner) + local def = assert(game.data.maps[id]) + local index = 0 + for _, existing in ipairs(def.objects) do + index = math.max(index, existing.index) + end + row.index, row.localId, row.runtime, row.owner = index + 1, index + 1, true, owner + def.objects[#def.objects + 1] = row + local publicId = id .. "_obj_" .. row.index + if id == self.home then + local actor = self:make(id, row) + ow.npcs[#ow.npcs + 1] = actor + ow.entities[#ow.entities + 1] = actor + ow.npcPool[publicId] = actor + if self.byId then + self.byId[row.localId] = actor + self.order[#self.order + 1] = row.localId + end + end + return publicId + end + function ow:addRuntimeObject(id, row, owner) + return f:spawn(id, row, owner) + end + function f:remove(id, owner) + for _, def in pairs(game.data.maps) do + for i, row in ipairs(def.objects or {}) do + if row.runtime and def.id .. "_obj_" .. row.index == id then + if row.owner ~= owner then + return nil, "not owned by " .. tostring(owner) + end + table.remove(def.objects, i) + for _, list in ipairs({ ow.npcs, ow.entities }) do + for j = #list, 1, -1 do + if list[j].id == id then + table.remove(list, j) + end + end + end + ow.npcPool[id] = nil + if self.byId then + self.byId[row.localId] = nil + for j = #self.order, 1, -1 do + if self.order[j] == row.localId then + table.remove(self.order, j) + end + end + end + return true + end + end + end + return nil, "no runtime object " .. tostring(id) + end + function ow:removeRuntimeObject(id, owner) + return f:remove(id, owner) + end + if backend == "gen1" and f.gen == 3 then + f.byId, f.order = { [1] = f.imported }, { 1 } + package.loaded["src.core.game3.objects"] = { + _byId = f.byId, + _order = f.order, + addRuntimeObject = function(id, row, owner) + return f:spawn(id, row, owner) + end, + removeRuntimeObject = function(id, owner) + return f:remove(id, owner) + end, + scriptFace = function(actor, dir) + actor.facing = dir + end, + scriptStep = function(actor, dir) + local delta = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[dir] + actor.facing = dir + actor.targetX = actor.cellX + delta[1] + actor.targetY = actor.cellY + delta[2] + actor.moving = true + end, + startTrack = function(id, actions, callback) + local count = 0 + for _, action in ipairs(actions) do + if action.kind == "step" then + count = count + 1 + end + end + return f:begin(f.byId[id], count > 0 and "move" or "march", actions[1].dir, count, callback) + end, + } + package.loaded["src.core.game3.collision"] = { + canEnter = function() + return true + end, + isWater = function() + return false + end, + } + end + function f:update() + return self.loader.hooks:call("input.step", function() + return "updated", nil, "tail" + end, game, 0) + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local ex = f.exports + local report = { invalid = {}, actions = {} } + local function refused(value, why) + assert(value == nil and type(why) == "string" and #why > 0, tostring(why)) + end + refused(ex.spawn("UNKNOWN", { x = 0, y = 0 })) + refused(ex.spawn(f.home, { x = -1, y = 0 })) + refused(ex.spawn(f.home, { x = 0.5, y = 0 })) + refused(ex.npc(f.home, false)) + refused(ex.remove(false)) + assert(#f.game.data.maps[f.home].objects == 1 and #f.ow.npcs == 1) + local definition = { name = "RUNTIME", sprite = "PROBE", x = 0, y = 0, movement = "STAY", range = "DOWN" } + local id = assert(ex.spawn(f.home, definition)) + local handle = assert(ex.npc(f.home, id)) + assert(handle.id == id and ex.npc(f.home, "RUNTIME").id == id) + definition.x, definition.sprite = 99, "changed" + local actor = f.ow.npcPool[id] + assert(actor.cellX == 0 and actor.def.sprite == "PROBE") + for _, method in ipairs({ "scriptMove", "face", "stepNow", "canStep" }) do + refused(handle[method](handle, "north")) + end + refused(handle:scriptMove("right", 0)) + refused(handle:scriptMove("right", 1025)) + refused(handle:scriptMove("right", 1, false)) + refused(handle:marchInPlace(false)) + refused(handle:placeAt(-1, 0)) + refused(handle:setPassable("yes")) + refused(handle:setAppearance("")) + assert(handle:face("right")) + local allowed, blocked = handle:canStep("right") + assert(allowed == true, tostring(allowed) .. ": " .. tostring(blocked)) + assert(handle:setPassable(true) and actor.passable == true) + assert(handle:setAppearance("ALT")) + assert(handle:position() == 0 and handle:isMoving() == false) + assert(handle:stepNow("right")) + assert(handle:isMoving() == true and actor.targetX == 1) + refused(handle:stepNow("right")) + assert(handle:placeAt(2, 2, "up")) + assert(handle:isMoving() == false and actor.px == 32 and actor.py == 32) + local x, y = handle:position() + assert(x == 2 and y == 2) + assert(handle:scriptMove("right", 2, ex.done)) + assert(ex.result().calls == 0) + f:complete("done", nil, "tail") + x, y = handle:position() + assert(x == 4 and y == 2) + assert(ex.result().calls == 1 and ex.result().args.n == 3 and ex.result().args.third == "tail") + assert(handle:marchInPlace(ex.done)) + f:complete("march") + x, y = handle:position() + assert(x == 4 and y == 2 and ex.result().calls == 2) + assert(handle:scriptMove("left")) + f:complete() + assert(handle:position() == 3 and ex.result().calls == 2) + assert(handle:marchInPlace()) + f:complete() + assert(handle:placeAt(4, 2)) + local imported = assert(ex.npc(f.home, "IMPORTED")) + refused(ex.remove(imported.id)) + local other = f:spawn(f.home, { name = "OTHER", sprite = "PROBE", x = 5, y = 5 }, "other") + refused(ex.remove(other)) + assert(ex.remove(id)) + refused(ex.npc(f.home, id)) + assert(ex.npc(f.home, other) and ex.npc(f.home, imported.id)) + report.actions = { + x = x, + y = y, + passable = actor.passable, + callbacks = ex.result().calls, + definitionCopied = true, + ownedRemoval = true, + } + report.invalid = true + assert(#f.loader.errors == 0) + f.restoreRenderer() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 0fe96b9ab..b288ae8c1 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -12,11 +12,14 @@ assert( or mode == "scaffold" or mode == "warp" or mode == "world_edits" + or mode == "npcs" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "world_edits" then +if mode == "npcs" then + dofile(codeRoot .. "/npcs.lua").run(root, backend, version) +elseif mode == "world_edits" then dofile(codeRoot .. "/world_edits.lua").run(root, backend, version) elseif mode == "warp" then dofile(codeRoot .. "/warp.lua").run(root, backend, version) diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index 2ce5e14a9..35e00aece 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -134,6 +134,9 @@ do T.eq(type(world.party), "function", "early world method callable") T.eq(type(battle.snapshot), "function", "early battle method callable") for _, name in ipairs({ + "spawnNpc", + "removeNpc", + "npc", "warpTo", "toggleObject", "replaceBlock", @@ -148,9 +151,6 @@ do T.eq(m:supports("world", name), true, "implemented world method " .. name) end for _, name in ipairs({ - "spawnNpc", - "removeNpc", - "npc", "startWildBattle", "invalidateMap", }) do @@ -217,6 +217,9 @@ end for _, version in ipairs({ "platinum", "black" }) do local m = boot(3, nil, false, version) T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) + for _, name in ipairs({ "spawnNpc", "removeNpc", "npc" }) do + T.eq(m:supports("world", name), false, "no shared NPC API promised for " .. version) + end T.eq(m:supports("world", "warpTo"), false, "no shared warp promised for " .. version) T.eq(m:supports("world", "toggleObject"), false, "no shared object edits promised for " .. version) T.eq(m:supports("world", "replaceBlock"), false, "no shared block edits promised for " .. version) diff --git a/tests/engine/api3_npc.lua b/tests/engine/api3_npc.lua new file mode 100644 index 000000000..f3c4844c4 --- /dev/null +++ b/tests/engine/api3_npc.lua @@ -0,0 +1,423 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.npcs") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local Renderer = require("src.render.SpriteRenderer") +local originalRenderer = Renderer.new +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +local function spawn(f) + local id = assert( + f.exports.spawn( + f.home, + { x = 0, y = 0, name = "OWN", sprite = "PROBE", movement = "STAY", range = "DOWN" } + ) + ) + return assert(f.exports.npc(f.home, id)), f.ow.npcPool[id] +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared NPC contract: " .. tostring(why)) +end +local cycle = { x = 0, y = 0 } +cycle.self = cycle +local meta = setmetatable({ x = 0, y = 0 }, {}) +local deep = { x = 0, y = 0 } +local child = deep +for _ = 1, 18 do + child.next = {} + child = child.next +end +local wide = { x = 0, y = 0 } +for i = 1, 260 do + wide[i] = i +end +for _, row in ipairs({ + cycle, + meta, + deep, + wide, + { x = 0, y = 0, handler = function() end }, + { x = math.huge, y = 0 }, + { x = 0 / 0, y = 0 }, + { x = 1000001, y = 0 }, + { x = 0, y = -1 }, + { x = 0, y = 0.5 }, + { x = 0, y = 0, name = "bad\nname" }, + { x = 0, y = 0, sprite = "missing" }, + { x = 0, y = 0, facing = "north" }, + { x = 0, y = 0, payload = string.rep("x", 4097) }, +}) do + local f = fixture() + local value, why = f.exports.spawn(f.home, row) + T.eq(value, nil, "invalid definition refused") + T.check(type(why) == "string", "definition failure explained") + T.eq(#f.game.data.maps[f.home].objects, 1, "invalid definition adds no template") + T.eq(#f.ow.npcs, 1, "invalid definition adds no actor") +end +for _, version in ipairs({ "red", "gold", "firered" }) do + for _, lock in ipairs({ "overlay", "player", "script", "link", "parallel", "controller" }) do + local f = fixture(version) + local handle, actor = spawn(f) + if lock == "overlay" then + f.game.stack.states[2] = {} + elseif lock == "player" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + elseif lock == "link" then + f.game.linkSession = {} + elseif lock == "parallel" then + f.ow.parallelRunners = { {} } + else + f.ow._modScriptLock = {} + end + T.eq(handle:position(), 0, "busy world permits position read") + T.eq(handle:isMoving(), false, "busy world permits motion read") + T.eq(handle:face("left"), nil, lock .. " blocks mutation") + T.eq(f.exports.spawn(f.home, { x = 0, y = 0 }), nil, lock .. " blocks spawn") + T.eq(f.exports.remove(handle.id), nil, lock .. " blocks removal") + T.eq(actor.facing, "down", "refusal does not change actor") + end +end +for _, method in ipairs({ "face", "stepNow", "scriptMove", "canStep" }) do + for _, bad in ipairs({ "north", false, {}, 1 }) do + local f = fixture() + local handle = spawn(f) + local ok, why = handle[method](handle, bad) + T.eq(ok, nil, "bad direction refused for " .. method) + T.eq(why, "invalid direction", "bad direction diagnosed") + T.eq(#f.ow.scriptMoves, 0, "no queued movement on invalid direction") + end +end +for _, bad in ipairs({ 0, -1, 0.5, math.huge, 0 / 0, 1025, "1" }) do + local f = fixture() + local handle = spawn(f) + T.eq(handle:scriptMove("up", bad), nil, "invalid move count refused") + T.eq(#f.ow.scriptMoves, 0, "invalid count has no effect") +end +do + local f = fixture() + local handle, actor = spawn(f) + local native = f.ow.addRuntimeObject + function f.ow:addRuntimeObject(...) + local value, why = handle:face("left") + T.eq(value, nil, "native construction cannot reenter NPC mutation") + T.eq(why, "world is busy", "NPC reentry diagnosed") + value, why = f.exports.toggle(f.home, "IMPORTED", false) + T.eq(value, nil, "NPC construction cannot reenter object edit") + T.eq(why, "world is busy", "shared mutation guard diagnosed") + return native(self, ...) + end + T.check(f.exports.spawn(f.home, { x = 2, y = 2, sprite = "PROBE" }), "outer construction succeeds") + T.eq(actor.facing, "down", "reentry leaves actor untouched") + T.check(handle:face("right"), "construction releases guard") + local constructor = Renderer.new + Renderer.new = function() + error("renderer failed", 0) + end + local def, sprite = actor.def, actor.sprite + T.eq(handle:setAppearance("ALT"), nil, "failed renderer construction contained") + T.eq(actor.def, def, "failed renderer retains original definition") + T.eq(actor.sprite, sprite, "failed renderer retains original sprite") + Renderer.new = constructor + T.check(handle:face("up"), "renderer exception releases guard") +end +for _, change in ipairs({ "game", "save", "data", "stack", "world", "map", "actor", "version", "bus" }) do + local f = fixture() + local handle, actor = spawn(f) + T.check(handle:scriptMove("right", 2, f.exports.done), "movement starts before " .. change) + local callback = f.pending.callback + local restore + if change == "game" then + local old = f.live + f.live = {} + restore = function() + f.live = old + end + elseif change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + elseif change == "world" then + local old = f.game.overworld + f.game.overworld = {} + restore = function() + f.game.overworld = old + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = require("src.mods.Events").new() + restore = function() + Runtime.events = old + end + elseif change == "actor" then + table.remove(f.ow.npcs, #f.ow.npcs) + restore = function() + f.ow.npcs[#f.ow.npcs + 1] = actor + end + else + local object = (change == "map") and f.ow or f.game + local old = object[change] + object[change] = {} + restore = function() + object[change] = old + end + end + f:update() + T.eq(#f.ow.scriptMoves, 0, "changed context retires only owned movement") + restore() + callback("stale") + callback("replayed") + T.eq(f.exports.result().calls, 0, "restored " .. change .. " cannot revive callback") +end +for _, event in ipairs({ "map.exited", "map.entered" }) do + local f = fixture() + local handle = spawn(f) + T.check(handle:scriptMove("right", 1, f.exports.done), "movement starts before map lifecycle") + local callback = f.pending.callback + Runtime.emit(event, { mapId = f.home }) + T.eq(#f.ow.scriptMoves, 0, "map lifecycle retires movement immediately") + callback() + T.eq(f.exports.result().calls, 0, "old map callback suppressed") +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + local handle, actor = spawn(f) + local foreign = f:spawn(f.home, { name = "OTHER", sprite = "PROBE", x = 5, y = 5 }, "other") + T.check(handle:scriptMove("right", 1, f.exports.done), "queue before " .. action) + local callback = f.pending.callback + local foreignEntry = { + entity = f.imported, + remaining = 1, + onDone = function() + error("do not invoke") + end, + } + f.ow.scriptMoves[#f.ow.scriptMoves + 1] = foreignEntry + if action == "rollback" then + f.loader:_rollback("npcs") + elseif action == "reload" then + fixture() + else + Loader.teardown() + end + T.eq(#f.ow.scriptMoves, 1, "unload removes only owned queue entries") + T.eq(f.ow.scriptMoves[1], foreignEntry, "foreign movement retained") + T.eq(#f.game.data.maps[f.home].objects, 2, "unload removes only own runtime template") + T.eq(f.ow.npcPool[handle.id], nil, "own actor removed from native pool") + T.check(f.ow.npcPool[foreign], "foreign owner retained") + callback() + T.eq(f.exports.result().calls, 0, "unload suppresses native callback") + T.eq(handle:position(), nil, "unloaded handle retired") + T.eq(f.exports.npc(f.home, "IMPORTED"), nil, "unloaded facade retired") +end +do + local f = fixture() + local handle, actor = spawn(f) + T.check(f.exports.remove(handle.id), "own removal succeeds") + local replacement = assert(f.exports.spawn(f.home, { name = "NEW", sprite = "PROBE", x = 1, y = 1 })) + T.eq(replacement, handle.id, "native ID can be reused") + local value, why = handle:face("left") + T.eq(value, nil, "old handle cannot change replacement with same ID") + T.eq(why, "NPC handle is stale", "stale handle diagnosed") + T.eq(f.exports.npc(f.home, replacement):position(), 1, "replacement remains untouched") +end +do + local f = fixture() + local handle, actor = spawn(f) + f.synchronous = true + T.check( + handle:scriptMove("right", 1, function(...) + T.eq(Runtime.currentMod, "npcs", "movement callback attributed") + f.exports.done(...) + f.synchronous = false + T.check(handle:face("up"), "synchronous callback runs after commit guard released") + end), + "synchronous move accepted" + ) + T.eq(f.exports.result().calls, 1, "synchronous callback called once") + T.eq(f.exports.result().args.n, 3, "nil argument holes preserved") + f.pending.callback() + T.eq(f.exports.result().calls, 1, "duplicate completion ignored") + T.eq(Runtime.currentMod, nil, "callback restores runtime owner") +end +for _, err in ipairs({ + "authored failure", + setmetatable({}, { + __tostring = function() + error("hostile tostring") + end, + }), +}) do + local f = fixture() + local handle = spawn(f) + T.check( + handle:scriptMove("right", 1, function() + error(err, 0) + end), + "throwing callback admitted" + ) + T.check( + pcall(function() + f:complete() + f.pending.callback() + end), + "callback errors cannot escape native completion" + ) + T.eq(#f.loader.errors, 1, "callback error attributed once") + T.check( + f.loader.errors[1]:find("npcs: NPC movement callback failed:", 1, true), + "callback error names owner" + ) +end +do + local f = fixture() + local handle = spawn(f) + f.failMovement, f.synchronous = true, true + T.eq(handle:scriptMove("right", 1, f.exports.done), nil, "native refusal never counts as admission") + T.eq(f.exports.result().calls, 0, "callback deferred until native result") + T.eq(#f.ow.scriptMoves, 0, "native refusal leaves no owned queue entry") +end +do + local f = fixture() + local handle = spawn(f) + local native = f.ow.scriptMove + function f.ow:scriptMove(...) + Runtime.emit("map.exited", { mapId = f.home }) + return native(self, ...) + end + T.eq(handle:scriptMove("right", 1, f.exports.done), nil, "map change during admission refuses completion") + T.eq(#f.ow.scriptMoves, 0, "late-created owned entry cleaned after cancellation race") +end +do + local f = fixture() + local handle = spawn(f) + T.check(handle:scriptMove("right"), "optional count/callback accepted") + f:complete() + T.eq(handle:position(), 1, "default count is one") + T.eq(#f.loader.errors, 0, "omitted callback never treated as a callable") +end +-- Real native object creation/removal, NPC updates and movement queue. +local OW = require("src.world.OverworldController") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.addRuntimeObject, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.addRuntimeObject, i, game) + return function() + debug.setupvalue(OW.addRuntimeObject, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local restore = bind(f.game) + f.ow.addRuntimeObject, f.ow.removeRuntimeObject = OW.addRuntimeObject, OW.removeRuntimeObject + f.ow.scriptMove, f.ow.marchInPlace = OW.scriptMove, OW.marchInPlace + local handle, actor = spawn(f) + actor.frozen = true + T.check(handle:scriptMove("right", 2, f.exports.done), version .. " native movement starts") + for _ = 1, 40 do + OW.updateScriptMoves(f.ow) + actor:update(f.ow.map, f.ow.entities) + end + T.eq(handle:position(), 2, "native queue walks exactly two cells") + T.eq(f.exports.result().calls, 1, "native completion exactly once") + T.eq(#f.ow.scriptMoves, 0, "native movement releases world input") + T.check(handle:marchInPlace(f.exports.done), "native march starts") + for _ = 1, 20 do + OW.updateScriptMoves(f.ow) + actor:update(f.ow.map, f.ow.entities) + end + T.eq(handle:position(), 2, "march does not translate") + T.eq(f.exports.result().calls, 2, "native march completes") + T.check(handle:setAppearance("ALT"), "native renderer refresh path") + T.eq(actor.def.sprite, "ALT", "actor appearance changed") + T.eq( + f.game.data.maps[f.home].objects[2].sprite, + "PROBE", + "native template data not rewritten by appearance" + ) + T.check(f.exports.remove(handle.id), "native removal succeeds") + T.eq(#f.ow.npcs, 1, "native removal retains imported actor") + restore() +end +do + local f = fixture() + local native = f.ow.addRuntimeObject + function f.ow:addRuntimeObject(...) + native(self, ...) + error("creation failure", 0) + end + T.eq(f.exports.spawn(f.home, { sprite = "PROBE", x = 0, y = 0 }), nil, "creation exception contained") + T.eq(#f.game.data.maps[f.home].objects, 1, "failed creation template rolled back") + T.eq(#f.ow.npcs, 1, "failed creation actor rolled back") + T.eq(next(f.ow.npcPool), f.imported.id, "failed creation leaves pool unchanged") +end +do + local f = fixture() + local row = + { name = "LATER", sprite = "PROBE", x = 1, y = 1, owner = "forged", runtime = false, index = 900 } + local id = assert(f.exports.spawn(f.dest, row)) + local record = f.game.data.maps[f.dest].objects[1] + T.eq(record.owner, "npcs", "caller cannot forge runtime ownership") + T.eq(record.runtime, true, "native runtime marker assigned") + T.eq(record.index, 1, "native identity replaces supplied index") + T.eq(#f.ow.npcs, 1, "inactive spawn adds no active actor") + local actor = f:make(f.dest, record) + actor.def = require("src.mods.Merge").deepCopy(record) -- Native placement/appearance clones. + local map = require("src.world.Map").new(f.game.data.maps[f.dest], f.ow.map.tileset) + f.ow.ghosts = { { npc = actor, map = map } } + f.ow.npcPool[id] = actor + local handle = assert(f.exports.npc(f.dest, id)) + T.eq(handle:position(), 1, "live neighbor uses its own coordinate frame") + T.eq(handle:canStep("right"), true, "neighbor collision uses neighbor map") + local homeHandle, homeActor = spawn(f) + homeActor.cellX, homeActor.cellY = 2, 1 + local nb = { map = map, cx = 0, cy = 0, peers = { actor } } + f.ow.neighbors = { nb } + f.ow.ghosts[1].nb = nb + require("src.world.OverworldController").updateCast(f.ow) + T.eq(handle:canStep("right"), false, "neighbor cast sees translated primary actor") + T.eq(homeHandle:canStep("left"), false, "primary cast sees translated neighbor actor") + homeActor.passable = true + actor.passable = true + require("src.world.OverworldController").updateCast(f.ow) + T.eq(handle:canStep("right"), true, "neighbor cast respects primary passability") + T.eq(homeHandle:canStep("left"), true, "primary cast respects neighbor passability") + T.check(handle:face("left"), "neighbor can face") + local ok, why = handle:scriptMove("right", 1) + T.eq(ok, nil, "neighbor track refused") + T.eq(why, "movement tracks require the active map", "neighbor restriction explicit") + Loader.teardown() + T.eq(#f.game.data.maps[f.dest].objects, 0, "inactive template cleaned on unload") + T.eq(#f.ow.ghosts, 0, "later neighbor actor clone cleaned on unload") + T.eq(f.ow.npcPool[id], nil, "neighbor pool cleaned on unload") +end +do + local f = fixture() + local handle, actor = spawn(f) + handle.id = f.imported.id + T.check(handle:face("left"), "public ID field cannot redirect private handle binding") + T.eq(actor.facing, "left", "original binding changed") + T.eq(f.imported.facing, "down", "imported actor unaffected") +end +Renderer.new = originalRenderer +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 public NPC actions") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index b20738ec1..fdaef60d3 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 602302a8362cea3e0238b4cf7d53b5226f72f269 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:50:10 +0200 Subject: [PATCH 16/71] Add guarded API 3 wild encounters and shared contracts --- docs/api3-portability.md | 9 +- docs/api3-wild-battle.md | 66 +++++++ docs/modding.md | 3 +- src/mods/Api3.lua | 9 +- src/mods/Api3WildBattle.lua | 177 +++++++++++++++++ src/mods/Loader.lua | 4 + tests/api3_contract/mods/wild_battle/main.lua | 16 ++ .../mods/wild_battle/manifest.json | 1 + tests/api3_contract/wild_battle.lua | 128 +++++++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/api3_compat.lua | 2 +- tests/engine/api3_wild_battle.lua | 179 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 13 files changed, 591 insertions(+), 10 deletions(-) create mode 100644 docs/api3-wild-battle.md create mode 100644 src/mods/Api3WildBattle.lua create mode 100644 tests/api3_contract/mods/wild_battle/main.lua create mode 100644 tests/api3_contract/mods/wild_battle/manifest.json create mode 100644 tests/api3_contract/wild_battle.lua create mode 100644 tests/engine/api3_wild_battle.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 5065c4c1a..2e11c84cd 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -81,11 +81,12 @@ The common world calls currently enabled for generations 1–3 are: - [`toggleObject` and `replaceBlock`](api3-world-edits.md) for validated native visibility and active block/metatile edits; - [`spawnNpc`, `removeNpc` and `npc`](api3-npc.md) for runtime NPC creation, - ownership-aware removal and opaque live actor controls. + ownership-aware removal and opaque live actor controls; +- [`startWildBattle`](api3-wild-battle.md) for validated native encounters. Check `mod:supports("world", name)` before relying on a call. The other Gen1 world method names remain callable but explicitly return -`nil, "unsupported: ..."`: `startWildBattle` and `invalidateMap` have not yet been verified against the +`nil, "unsupported: ..."`: `invalidateMap` has not yet been verified against the Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. Gen4/5 do not claim the shared world surface in this first entry PR. @@ -132,8 +133,8 @@ The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. -Eight object/block cases and eight NPC cases extend this to 48 comparisons -and 96 isolated loader runs. It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 56 comparisons +and 112 isolated loader runs (including eight wild-battle cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/api3-wild-battle.md b/docs/api3-wild-battle.md new file mode 100644 index 000000000..d6896e2f5 --- /dev/null +++ b/docs/api3-wild-battle.md @@ -0,0 +1,66 @@ +# API-3 wild encounters + +`mod.world:startWildBattle(species, level, onDone?)` starts a native wild +encounter for generations 1–3. Check `mod:supports("world", "startWildBattle")`. +Capture `mod.world` at entry and call later from an idle world. Gen4/5 decline; +API-1/2 behavior and the portable script vocabulary remain unchanged. + +```lua +local mod = ... +local world = mod.world +local function encounter() + return world:startWildBattle("MEW", 30, function(result) + mod.log:info("Encounter ended: %s", tostring(result)) + end) +end +``` + +The call returns `true` for admission or `nil, reason`. Species must be a +nonempty identifier of at most 256 bytes without control characters; resolve +it through the active dataset's canonical species index. Unknown/ambiguous +species are refused. Level must be a number, a whole integer from 1 to 100; +strings are not coerced. The optional callback must be a function. + +A current save, a healthy non-egg party member and the topmost idle overworld +are required. Menus, battles/transitions, scripts/parallel scripts, movement, +link sessions, controller locks and reentry decline before native construction. +Refused arguments do not consume constructor RNG or mark the species seen. +Reading the facade/early calls never bootstrap Game. + +Use the existing native BattleState constructor, overworld pushBattle and +post-battle afterBattle path. This preserves native enemy creation, entry +presentation/music and the usual evolution/blackout handling. Do not substitute +a hand-built battle screen. The host can still refuse or throw after native +construction; already entered native effects are not a transactional patch. + +Completion is once per battle, after native afterBattle has run. Native result +arguments, including nil holes, reach the optional callback. Synchronous native +completion is deferred until admission/guard release; rejected admission never +calls it. Callback errors are contained and attributed once. A completion may +start a later action subject to its normal idle-world checks. + +Captured Game/save/data/stack/world/player/version/bus and owner identities are +revalidated. Observed session changes permanently retire pending mod callbacks; +input.step observes them before and after the native update. Rollback, reload +and teardown retire the owner's callback/facade. An already admitted native +battle is not removed when a mod unloads: its native cleanup still runs if the +original game/session remains current. A late result cannot run cleanup against +a replaced session. Native cleanup failure is reported and suppresses the mod +callback; duplicate completion does not repeat native cleanup. + +## Verification + +Run `luajit tests/engine/api3_wild_battle.lua` and the pinned cross-engine checker +from [API-3 portability](api3-portability.md). The identical public fixture runs +on both production loaders for Red, Blue, Yellow, Gold, Silver, Crystal, +FireRed and Emerald, extending the checker to 56 comparisons / 112 isolated +runs. It covers early calls, invalid arguments, healthy-party checks, native +admission, busy-world refusal and synchronous/asynchronous callback results. +Native battle construction/entry/timing are seams in the shared driver. + +Local tests also exercise actual host BattleState/Pokemon construction and +Pokedex seen updates on synthetic datasets, session replacement/restore, +mod rollback/reload/unload, native failure, callback replay/errors and guard +release. Transition and afterBattle entry are instrumented seams; these are +ROM-free runtime boundary tests, not a retail-ROM battle/evolution/blackout +playthrough or rendered transition equivalence. diff --git a/docs/modding.md b/docs/modding.md index d6169c421..0c610a201 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -25,7 +25,8 @@ using the same manifest and mod source on Gen1Recomp and Gen2Recomped. API 3 selects the unified adapters automatically; API 1/2 keep their defaults. See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime, [API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes, -and [API-3 NPC actions](api3-npc.md) for runtime actors and movement handles. +[API-3 NPC actions](api3-npc.md) for runtime actors and movement handles, +and [API-3 wild encounters](api3-wild-battle.md) for native battle admission. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 8e678aca5..9ac146e65 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -5,6 +5,7 @@ local Runtime = require("src.mods.Runtime") local Warp = require("src.mods.Api3Warp") local Edits = require("src.mods.Api3WorldEdits") local NPC = require("src.mods.Api3NPC") +local Wild = require("src.mods.Api3WildBattle") local copy = require("src.mods.Merge").deepCopy local A = {} local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty @@ -15,7 +16,7 @@ local SUPPORTED = {} for name in ([[overworld current activeBlockAt party canReorderParty reorderParty availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters - setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc]]):gmatch( + setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc startWildBattle]]):gmatch( "%S+" ) do @@ -83,10 +84,11 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) end return nativeWorld end - local warp, licensed, npc + local warp, licensed, npc, wild if supportedGeneration then warp, licensed = Warp.new(loader, api.id, gen, current, native) npc = NPC.new(loader, api.id, gen, current, native, licensed, engineRequire) + wild = Wild.new(loader, api.id, current, native, licensed, engineRequire) end local function dispatch(name, ...) if not supportedGeneration or not SUPPORTED[name] then @@ -95,6 +97,9 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) if name == "spawnNpc" or name == "removeNpc" or name == "npc" then return npc(name, ...) end + if name == "startWildBattle" then + return wild(...) + end if name == "warpTo" then return warp(...) end diff --git a/src/mods/Api3WildBattle.lua b/src/mods/Api3WildBattle.lua new file mode 100644 index 000000000..0cb0ba143 --- /dev/null +++ b/src/mods/Api3WildBattle.lua @@ -0,0 +1,177 @@ +-- API-3 encounter admission over native construction, transition and afterBattle. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Edits = require("src.mods.Api3WorldEdits") +local Ids = require("src.mods.UnifiedIds") +local Party = require("src.pokemon.Party") +local B = {} +local pending, observers = {}, setmetatable({}, { __mode = "k" }) +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function text(value) + local ok, result = pcall(tostring, value) + return ok and result or "unprintable error" +end +local function retire(token) + token.retired, token.callback = true, nil + pending[token] = nil +end +function B.cancelAll(loader, owner) + for token in pairs(pending) do + if (not loader or loader == token.loader) and (not owner or owner == token.owner) then + retire(token) + end + end +end +local function inspect(loader) + for token in pairs(pending) do + if token.loader == loader and not token.valid() then + retire(token) + end + end +end +local function observe(loader) + if observers[loader] == loader.hooks then + return + end + observers[loader] = loader.hooks + loader.hooks:wrap("input.step", function(nextFn, ...) + inspect(loader) + local result = pack(pcall(nextFn, ...)) + inspect(loader) + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, math.huge, "engine:api3_wild_battle") +end +function B.new(loader, owner, current, native, licensed, req) + local ids = Ids.new(loader) + observe(loader) + return function(species, level, callback) + if not licensed() then + return nil, "world facade is retired" + end + local game = current() + if not game then + return nil, "no game" + end + local ow = native(game):overworld() + if not ow then + return nil, "no overworld" + end + local ok, reason = Edits.ready(game, ow) + if not ok then + return nil, reason + end + for token in pairs(pending) do + if token.game == game then + if token.valid() then + return nil, "world is busy" + end + retire(token) + end + end + if type(species) ~= "string" or #species == 0 or #species > 256 or species:find("%c") then + return nil, "invalid species id" + end + if type(level) ~= "number" or level ~= level or level < 1 or level > 100 or level % 1 ~= 0 then + return nil, "level must be a whole number 1..100" + end + if callback ~= nil and type(callback) ~= "function" then + return nil, "invalid callback" + end + local group, why = ids:resolve("pokemon", species) + if not group then + return nil, why:find("^ambiguous") and why or "unknown species: " .. species + end + local key = group.key + if not (game.data.pokemon and game.data.pokemon[key]) then + return nil, "unknown species: " .. species + end + if not Party.firstHealthy(game.save.party or {}) then + return nil, "no healthy party" + end + if type(ow.pushBattle) ~= "function" or type(ow.afterBattle) ~= "function" then + return nil, "battle runtime unavailable" + end + local save, data, stack, player, world = game.save, game.data, game.stack, ow.player, game.overworld + local version, hooks, events = Version.get(), Runtime.hooks, Runtime.events + local token = { loader = loader, owner = owner, game = game, callback = callback } + local function live() + return current() == game + and game.save == save + and game.data == data + and game.stack == stack + and game.overworld == world + and ow.player == player + and native(game):overworld() == ow + and Version.get() == version + end + function token.valid() + return not token.retired + and licensed() + and live() + and Runtime.hooks == hooks + and Runtime.events == events + end + local admitted, deferred, finished, battle + local function finish(...) + if finished then + return + end + if admitted == nil then + if not deferred then + deferred = pack(...) + end + return + end + finished = true + local valid, fn = token.valid(), token.callback + retire(token) + if not admitted or not live() then + return + end + -- Native cleanup still runs after the originating mod has been unloaded. + local called, err = pcall(ow.afterBattle, ow, (...), battle) + if not called then + Runtime.reportError(owner, "world battle cleanup failed: " .. text(err)) + return + end + if valid and licensed() and live() and fn then + local previous = Runtime.currentMod + Runtime.currentMod = owner + called, err = pcall(fn, ...) + Runtime.currentMod = previous + if not called then + Runtime.reportError(owner, "world battle callback failed: " .. text(err)) + end + end + end + pending[token] = true + local called, accepted, err = pcall(Edits.run, game, function() + battle = req("src.battle.BattleState").newWild(game, key, level) + if not battle or battle.dead then + return nil, "battle failed" + end + battle.onFinish = finish + local value, detail = ow:pushBattle(battle) + if value == false or value == nil and detail ~= nil then + return nil, detail or "battle failed" + end + return true + end) + admitted = called and accepted == true + if not admitted then + retire(token) + return nil, called and (err or "battle failed") or "world operation failed: " .. text(accepted) + end + if deferred then + finish(unpack(deferred, 1, deferred.n)) + end + return true + end +end +return B diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index c24867e06..c2ccabb47 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -14,6 +14,7 @@ local ModUI = require("src.ui.ModUI") local PortableScripts = require("src.mods.PortableScripts") local Api3Warp = require("src.mods.Api3Warp") local Api3NPC = require("src.mods.Api3NPC") +local Api3WildBattle = require("src.mods.Api3WildBattle") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") @@ -1264,6 +1265,7 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + Api3WildBattle.cancelAll(self, modId) Api3NPC.cancelAll(self, modId) Api3Warp.cancelAll(self, modId) PortableScripts.cancelAll(self, modId, "mod rolled back") @@ -1381,6 +1383,7 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + Api3WildBattle.cancelAll() Api3NPC.cancelAll() Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods reloaded") @@ -1727,6 +1730,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + Api3WildBattle.cancelAll() Api3NPC.cancelAll() Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods unloaded") diff --git a/tests/api3_contract/mods/wild_battle/main.lua b/tests/api3_contract/mods/wild_battle/main.lua new file mode 100644 index 000000000..ae198c01d --- /dev/null +++ b/tests/api3_contract/mods/wild_battle/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local world = mod.world +assert(mod:supports("world", "startWildBattle")) +local ok, why = world:startWildBattle("MEW", 5) +assert(ok == nil and why == "no game") +local calls, args = 0 +mod.exports.request = function(...) + return world:startWildBattle(...) +end +mod.exports.notify = function(...) + calls = calls + 1 + args = { n = select("#", ...), first = (...), third = select(3, ...) } +end +mod.exports.result = function() + return { calls = calls, args = args } +end diff --git a/tests/api3_contract/mods/wild_battle/manifest.json b/tests/api3_contract/mods/wild_battle/manifest.json new file mode 100644 index 000000000..492c88378 --- /dev/null +++ b/tests/api3_contract/mods/wild_battle/manifest.json @@ -0,0 +1 @@ +{"id":"wild_battle","name":"Wild battle contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/wild_battle.lua b/tests/api3_contract/wild_battle.lua new file mode 100644 index 000000000..bbfa86a1f --- /dev/null +++ b/tests/api3_contract/wild_battle.lua @@ -0,0 +1,128 @@ +-- Production public facades/loaders; native battle entry/timing seams only. +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/wild_battle%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "wild_battle") + love = love or require("tests.love_stub") + f.game.save.party = { { species = "MEW", hp = 20, level = 10, stats = { hp = 20 } } } + f.game.save.pokedex = { seen = {}, caught = {} } + f.constructed, f.pushed, f.after = 0, 0, 0 + function f.ow:afterBattle(result, battle) + f.after = f.after + 1 + f.lastResult, f.cleanedBattle = result, battle + if f.cleanupError then + error("native cleanup probe", 0) + end + end + function f:admit(battle, callback, nativeCleanup) + self.pushed = self.pushed + 1 + self.battle, self.finish, self.nativeCleanup = battle, callback, nativeCleanup + self.busy = true + self.game.stack.states[2] = battle + if self.synchronous then + self:complete("win", nil, "tail") + end + if self.pushFailure then + return false, "battle failed" + end + -- Native host admission is void on success. + end + function f:complete(...) + self.busy = false + self.game.stack.states[2] = nil + if self.nativeCleanup and not self.cleaned then + self.cleaned = true + self.ow:afterBattle((...), self.battle) + end + return self.finish(...) + end + local original = package.loaded["src.battle.BattleState"] + local function construct(game, species, level) + f.constructed = f.constructed + 1 + game.save.pokedex.seen[species] = true + if f.constructError then + error("native constructor probe", 0) + end + return { kind = "wild", species = species, level = level } + end + package.loaded["src.battle.BattleState"] = { newWild = construct } + f.restore = function() + package.loaded["src.battle.BattleState"] = original + end + function f.ow:pushBattle(battle) + return f:admit(battle, battle.onFinish, false) + end + if backend == "gen1" and f.gen == 2 then + package.loaded["src.battle.gen2.Mon"] = { + new = function(data, species, level) + return construct(f.game, species, level) + end, + } + function f.ow:startBattle(spec, onDone) + local result, reason = f:admit(spec.wild, onDone or function() end, true) + if result == false then + return result, reason + end + return true + end + elseif backend == "gen1" and f.gen == 3 then + local pokemon = require("src.core.game3.pokemon") + for key, value in pairs(f.game.data.gen3Pokemon) do + pokemon[key] = value + end + pokemon._byName = { MEW = 151 } + package.loaded["src.core.game3.battle_bridge"] = { + startWild = function(_, game, spec, opts) + local b = construct(game, spec.species, spec.level) + local result, reason = f:admit(b, opts and opts.done or function() end, true) + if result == false then + return result, reason + end + return true + end, + } + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local report = { invalid = {} } + for _, args in ipairs({ + { "", 5 }, + { "NO_SUCH_SPECIES", 5 }, + { "MEW", 0 }, + { "MEW", 1.5 }, + { "MEW", 101 }, + { "MEW", 5, false }, + }) do + local value, why = f.exports.request(unpack(args)) + assert(value == nil, "invalid battle accepted") + report.invalid[#report.invalid + 1] = why + assert(f.constructed == 0 and f.pushed == 0 and next(f.game.save.pokedex.seen) == nil) + end + f.game.save.party[1].hp = 0 + local value, why = f.exports.request("MEW", 5) + assert(value == nil and why == "no healthy party") + report.empty = why + f.game.save.party[1].hp = 20 + assert(f.exports.request("MEW", 5, f.exports.notify)) + assert(f.battle.level == 5 and f.pushed == 1) + value, why = f.exports.request("MEW", 5) + assert(value == nil and why == "world is busy") + f:complete("win", nil, "tail") + assert(f.after == 1) + f:complete("lose") + local r = f.exports.result() + assert(r.calls == 1 and r.args.n == 3 and r.args.third == "tail") + report.complete = r + f.restore() + f = M.fixture(root, backend, version) + f.synchronous = true + assert(f.exports.request("MEW", 100, f.exports.notify)) + assert(f.exports.result().calls == 1 and f.after == 1) + report.synchronous = f.exports.result() + f.restore() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index b288ae8c1..94879f89b 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -13,11 +13,14 @@ assert( or mode == "warp" or mode == "world_edits" or mode == "npcs" + or mode == "wild_battle" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "npcs" then +if mode == "wild_battle" then + dofile(codeRoot .. "/wild_battle.lua").run(root, backend, version) +elseif mode == "npcs" then dofile(codeRoot .. "/npcs.lua").run(root, backend, version) elseif mode == "world_edits" then dofile(codeRoot .. "/world_edits.lua").run(root, backend, version) diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index 35e00aece..4c8f8ac29 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -134,6 +134,7 @@ do T.eq(type(world.party), "function", "early world method callable") T.eq(type(battle.snapshot), "function", "early battle method callable") for _, name in ipairs({ + "startWildBattle", "spawnNpc", "removeNpc", "npc", @@ -151,7 +152,6 @@ do T.eq(m:supports("world", name), true, "implemented world method " .. name) end for _, name in ipairs({ - "startWildBattle", "invalidateMap", }) do local available, why = m:supports("world", name) diff --git a/tests/engine/api3_wild_battle.lua b/tests/engine/api3_wild_battle.lua new file mode 100644 index 000000000..71bafc967 --- /dev/null +++ b/tests/engine/api3_wild_battle.lua @@ -0,0 +1,179 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.wild_battle") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local Battle = require("src.battle.BattleState") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared battle contract: " .. tostring(why)) +end +for _, level in ipairs({ -1, 0, 1.5, 101, math.huge, 0 / 0, "5", false, {} }) do + local f = fixture() + T.eq(f.exports.request("MEW", level), nil, "invalid level refused") + T.eq(f.constructed, 0, "invalid level never constructs/marks seen") + f.restore() +end +for _, lock in ipairs({ "overlay", "moving", "script", "parallel", "link", "transition", "no_save" }) do + local f = fixture() + if lock == "overlay" then + f.game.stack.states[2] = {} + elseif lock == "moving" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + elseif lock == "parallel" then + f.ow.parallelQueue = { {} } + elseif lock == "link" then + f.game.linkSession = {} + elseif lock == "transition" then + f.ow.transitioning = true + else + f.game.save = nil + end + T.eq(f.exports.request("MEW", 5), nil, lock .. " blocks battle") + T.eq(f.constructed, 0, lock .. " leaves dex/RNG untouched") + f.restore() +end +for _, change in ipairs({ "game", "save", "data", "stack", "world", "player", "version", "bus" }) do + local f = fixture() + T.check(f.exports.request("MEW", 5, f.exports.notify), "start before " .. change) + local restore + if change == "game" then + f.live = {} + restore = function() + f.live = f.game + end + elseif change == "world" then + f.game.overworld = {} + restore = function() + f.game.overworld = f.ow + end + elseif change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = {} + restore = function() + Runtime.events = old + end + else + local object = change == "player" and f.ow or f.game + local old = object[change] + object[change] = {} + restore = function() + object[change] = old + end + end + f.loader.hooks:call("input.step", function() end) + restore() + f:complete("win") + T.eq(f.exports.result().calls, 0, "observed replacement permanently retires callback") + T.eq(f.after, 1, "restored native session still completes cleanup") + f.restore() +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + T.check(f.exports.request("MEW", 5, f.exports.notify), "start before " .. action) + if action == "rollback" then + f.loader:_rollback("wild_battle") + elseif action == "reload" then + local nextFixture = fixture() + nextFixture.restore() + else + Loader.teardown() + end + f:complete("caught") + T.eq(f.after, 1, "unloading mod preserves native cleanup") + T.eq(f.exports.result().calls, 0, "unloading retires callback") + T.eq(f.exports.request("MEW", 5), nil, "unloaded facade retired") + f.restore() +end +do + local f = fixture() + T.check(f.exports.request("MEW", 5, f.exports.notify), "start before foreign save") + f.game.save = {} + f:complete("lose") + T.eq(f.after, 0, "late result cannot run cleanup against replaced save") + T.eq(f.exports.result().calls, 0, "late result cannot call mod on replaced save") + f.restore() +end +for _, mode in ipairs({ "constructError", "pushFailure", "cleanupError", "callbackError", "syncRefusal" }) do + local f = fixture() + local callback = f.exports.notify + if mode == "callbackError" then + callback = function() + error("authored callback probe") + end + elseif mode == "syncRefusal" then + f.synchronous, f.pushFailure = true, true + else + f[mode] = true + end + local value = f.exports.request("MEW", 5, callback) + if mode == "constructError" or mode == "pushFailure" or mode == "syncRefusal" then + T.eq(value, nil, "failed admission returns nil") + T.eq(f.after, 0, "failed admission never invokes native completion") + T.eq(f.exports.result().calls, 0, "failed admission never calls mod") + else + T.check(value, "completion-error battle admitted") + T.check(pcall(f.complete, f, "win"), "completion errors contained") + T.eq(#f.loader.errors, 1, "completion error attributed once") + f:complete("win") + T.eq(#f.loader.errors, 1, "replay cannot repeat error") + end + f.restore() +end +do + local f = fixture() + f.synchronous = true + T.check( + f.exports.request("MEW", 5, function(...) + T.eq(Runtime.currentMod, "wild_battle", "completion owner restored") + f.exports.notify(...) + f.synchronous = false + T.check(f.exports.request("MEW", 6), "synchronous completion releases admission guard") + end), + "synchronous battle admitted" + ) + T.eq(f.pushed, 2, "callback can start next battle") + f:complete("run") + f.restore() +end +-- Real native BattleState/Pokemon construction with fixture data. The entry +-- transition and cleanup seams are independent from the public adapter. +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + f.restore() + local data = T.fixtures.fresh() + data.pokemon.MEW = require("src.mods.Merge").deepCopy(data.pokemon.FIXMON_A) + data.pokemon.MEW.id, data.pokemon.MEW.name = "MEW", "MEW" + data.constants.gen = Version.generation(version) + f.game.data = data + f.game.save = require("src.core.SaveData").newGame() + f.game.session = f.game.save + f.game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 15) } + require("src.render.Font").load(data) + T.check(f.exports.request("MEW", 5, f.exports.notify), version .. " native constructor admitted") + T.eq(f.battle.kind, "wild", "native battle kind") + T.eq(f.battle.enemy.mon.species, "MEW", "native enemy species") + T.eq(f.battle.enemy.mon.level, 5, "native level") + T.eq(f.game.save.pokedex.seen.MEW, true, "native constructor marks seen") + f:complete("caught") + T.eq(f.after, 1, "native battle retains cleanup") + T.eq(f.exports.result().calls, 1, "native battle completes public callback") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 public wild battle") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index fdaef60d3..98b44713d 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From b191fdcc3d904e51619e250f0c984236e8980aed Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:54:24 +0200 Subject: [PATCH 17/71] Add guarded API 3 native map invalidation --- docs/api3-invalidate-map.md | 57 ++++++++ docs/api3-portability.md | 19 +-- docs/modding.md | 3 +- src/mods/Api3.lua | 4 +- src/mods/Api3WorldEdits.lua | 22 ++- tests/api3_contract/invalidate.lua | 54 ++++++++ tests/api3_contract/mods/invalidate/main.lua | 15 +++ .../mods/invalidate/manifest.json | 1 + tests/api3_contract/worker.lua | 5 +- tests/engine/api3_compat.lua | 13 +- tests/engine/api3_invalidate.lua | 126 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 12 files changed, 295 insertions(+), 26 deletions(-) create mode 100644 docs/api3-invalidate-map.md create mode 100644 tests/api3_contract/invalidate.lua create mode 100644 tests/api3_contract/mods/invalidate/main.lua create mode 100644 tests/api3_contract/mods/invalidate/manifest.json create mode 100644 tests/engine/api3_invalidate.lua diff --git a/docs/api3-invalidate-map.md b/docs/api3-invalidate-map.md new file mode 100644 index 000000000..a815db481 --- /dev/null +++ b/docs/api3-invalidate-map.md @@ -0,0 +1,57 @@ +# API-3 map invalidation + +`mod.world:invalidateMap(mapId)` requests the host's native cache invalidation +and reload for generations 1–3. Capture `mod.world` at entry and call later from +an idle world. Return `true` or `nil, reason`; query availability through +`mod:supports("world", "invalidateMap")`. Gen4/5 decline. API-1/2 behavior and +the portable script vocabulary are unchanged. + +```lua +local mod = ... +local world = mod.world +local function refresh(mapId) + return world:invalidateMap(mapId) +end +``` + +A known map ID is required: a nonempty string up to 256 bytes without control +characters. Invalid or unknown IDs return `nil, "unknown map"`, matching Gen1's +API-3 preflight. Gen3 also resolves unprefixed names using the current version's +FR_/EM_/RU_/SA_ prefix when no exact ID exists. Map IDs remain dataset-specific. + +This shares NPC/object/block mutation admission and reentry protection. A save +and topmost idle overworld are required. Scripts, queued/parallel work, player +movement, transitions, menus, link sessions and controller locks refuse before +cache writes. Missing native reload support declines. Reading the facade never +constructs Game. Rollback/reload/teardown retire retained mutation facades. + +The host's native reloadMap invalidates the target and resident neighbor cache +entries. For the active map it uses setMap with seamless reload, current player +cell/facing and keepMusic. If the retained player cell no longer fits the new +geometry, native reload sends the player to the heal point. Runtime map/actor +instance identity can change, so obtain fresh NPC handles after a reload. +Inactive invalidation does not move the player or force an unrelated active +map reload. Rendering and map lifecycle events remain native, not a new common +renderer/cache implementation. Successful host invalidation emits map.reloaded +with mapId and reason="invalidate". Pinned Gen1's Gen2 backend does not emit +that event for inactive invalidation; this native difference is retained. + +This is a cache/instance operation, not a content-write API. Permanent map +changes belong in content definitions. A native failure returns nil with its +reason but may occur after cache invalidation; already entered native effects +are not transactionally rolled back. + +## Verification + +Run `luajit tests/engine/api3_invalidate.lua` and the pinned checker documented +in [API-3 portability](api3-portability.md). Eight identical public mod cases +extend the full checker to 64 comparisons / 128 isolated loader runs. Real +loaders/native facades compare preflight, active reload, position retention, +notification and inactive-map acceptance; reload timing is a driver seam. + +Host tests additionally run actual OverworldState.reloadMap and +MapLoader.invalidate against a synthetic cache, with the setMap/render entry +instrumented. Verify target/neighbor eviction, seamless/music options, valid +player retention, heal-point fallback, reentry, failure/guard release, busy +worlds and retired owners. No ROM import or rendered gameplay equivalence is +claimed. diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 2e11c84cd..f7ff88609 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -82,14 +82,15 @@ The common world calls currently enabled for generations 1–3 are: visibility and active block/metatile edits; - [`spawnNpc`, `removeNpc` and `npc`](api3-npc.md) for runtime NPC creation, ownership-aware removal and opaque live actor controls; -- [`startWildBattle`](api3-wild-battle.md) for validated native encounters. +- [`startWildBattle`](api3-wild-battle.md) for validated native encounters; +- [`invalidateMap`](api3-invalidate-map.md) for guarded native cache/reload actions. -Check `mod:supports("world", name)` before relying on a call. The other -Gen1 world method names remain callable but explicitly return -`nil, "unsupported: ..."`: `invalidateMap` has not yet been verified against the -Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native -symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. -Gen4/5 do not claim the shared world surface in this first entry PR. +Check `mod:supports("world", name)` before relying on a call. All 24 world +method names in the pinned Gen1 API-3 facade now have host adapters for Gen1–3. +This describes API availability, not identical native internals or full +cross-engine behavioral coverage. Native flag mappings and `mod.datasets:open` +remain unavailable; private mod flags are supported. Gen4/5 do not claim this +shared world surface. Private flags use `save.modData[modId].worldFlags`, independently of native flags and other mods. A read does not create a bucket. Clearing removes the @@ -133,8 +134,8 @@ The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. -Eight object/block cases and eight NPC cases extend this to 56 comparisons -and 112 isolated loader runs (including eight wild-battle cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 64 comparisons +and 128 isolated loader runs (including eight wild-battle and eight map-invalidation cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/modding.md b/docs/modding.md index 0c610a201..e2838031f 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -26,7 +26,8 @@ API 3 selects the unified adapters automatically; API 1/2 keep their defaults. See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime, [API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes, [API-3 NPC actions](api3-npc.md) for runtime actors and movement handles, -and [API-3 wild encounters](api3-wild-battle.md) for native battle admission. +[API-3 wild encounters](api3-wild-battle.md) for native battle admission, +and [API-3 map invalidation](api3-invalidate-map.md) for native cache/reload. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 9ac146e65..b64882e93 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -16,7 +16,7 @@ local SUPPORTED = {} for name in ([[overworld current activeBlockAt party canReorderParty reorderParty availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters - setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc startWildBattle]]):gmatch( + setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc startWildBattle invalidateMap]]):gmatch( "%S+" ) do @@ -103,7 +103,7 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) if name == "warpTo" then return warp(...) end - local edit = name == "toggleObject" or name == "replaceBlock" + local edit = name == "toggleObject" or name == "replaceBlock" or name == "invalidateMap" if edit and not licensed() then return nil, "world facade is retired" end diff --git a/src/mods/Api3WorldEdits.lua b/src/mods/Api3WorldEdits.lua index c21586e25..4b48c180d 100644 --- a/src/mods/Api3WorldEdits.lua +++ b/src/mods/Api3WorldEdits.lua @@ -130,6 +130,26 @@ local function replace(game, ow, api, gen, req, bx, by, block) Runtime.emit("world.block_replaced", { mapId = ow.map.id, bx = bx, by = by, block = block }) return true end +local function invalidate(game, ow, api, gen, req, mapId) + if not identifier(mapId) then + return nil, "unknown map" + end + local maps = game.data and game.data.maps + local id = mapId + if gen == 3 and maps and not maps[id] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if maps[prefix .. id] then + id = prefix .. id + end + end + if not (maps and maps[id]) then + return nil, "unknown map" + end + if type(ow.reloadMap) ~= "function" then + return nil, "map reload unavailable" + end + return api:invalidateMap(id) +end -- Shared admission/commit guard for synchronous API-3 world mutations. function E.ready(game, ow) local ok, reason = Actions.ready(game, ow) @@ -159,7 +179,7 @@ function E.call(game, api, generation, req, name, ...) if not ok then return nil, reason end - local operation = name == "toggleObject" and toggle or replace + local operation = name == "toggleObject" and toggle or name == "invalidateMap" and invalidate or replace return E.run(game, operation, game, ow, api, generation, req, ...) end return E diff --git a/tests/api3_contract/invalidate.lua b/tests/api3_contract/invalidate.lua new file mode 100644 index 000000000..56b11041a --- /dev/null +++ b/tests/api3_contract/invalidate.lua @@ -0,0 +1,54 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/invalidate%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "invalidate") + local Runtime = require("src.mods.Runtime") + f.reloads, f.loads = 0, {} + function f:reload(id, why) + self.reloads = self.reloads + 1 + self.loads[#self.loads + 1] = { id, why } + if self.reloadError then + error("reload probe", 0) + end + if self.ow.map.id == id then + self.ow.map = { id = id, def = self.game.data.maps[id] } + end + end + function f.ow:reloadMap(id, why) + f:reload(id, why) + Runtime.emit("map.reloaded", { mapId = id, reason = why }) + end + function f.ow:reloadMapBadWarp() + f:reload(self.map.id, "invalidate") + end + if backend == "gen1" and f.gen == 3 then + function f.map.load(_, game, id, opts) + assert(game == f.game and opts.x == f.player.cellX and opts.facing == f.player.facing) + f:reload(id, "invalidate") + end + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local report = { invalid = {} } + for _, id in ipairs({ "", "NO_SUCH_MAP", "HOME\n", 42, false }) do + local value, why = f.exports.request(id) + assert(value == nil and why == "unknown map") + assert(f.reloads == 0 and #f.exports.events() == 0) + report.invalid[#report.invalid + 1] = why + end + local old = f.ow.map + assert(f.exports.request(f.home)) + assert(f.reloads == 1 and f.ow.map ~= old) + assert(f.player.cellX == 0 and f.player.cellY == 0 and f.player.facing == "left") + assert(#f.exports.events() == 1 and f.exports.events()[1].mapId == f.home) + report.active = f.exports.events() + f = M.fixture(root, backend, version) + assert(f.exports.request(f.dest)) + assert(f.ow.map.id == f.home) + report.inactive = true -- Native inactive notification differences are not normalized. + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/mods/invalidate/main.lua b/tests/api3_contract/mods/invalidate/main.lua new file mode 100644 index 000000000..efed5c918 --- /dev/null +++ b/tests/api3_contract/mods/invalidate/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local world = mod.world +assert(mod:supports("world", "invalidateMap")) +local value, why = world:invalidateMap("HOME") +assert(value == nil and why == "no game") +local events = {} +mod.events:on("map.reloaded", function(ev) + events[#events + 1] = { mapId = ev.mapId, reason = ev.reason } +end) +mod.exports.request = function(...) + return world:invalidateMap(...) +end +mod.exports.events = function() + return events +end diff --git a/tests/api3_contract/mods/invalidate/manifest.json b/tests/api3_contract/mods/invalidate/manifest.json new file mode 100644 index 000000000..4a1728907 --- /dev/null +++ b/tests/api3_contract/mods/invalidate/manifest.json @@ -0,0 +1 @@ +{"id":"invalidate","name":"Map invalidation contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 94879f89b..42bcaf08b 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -14,11 +14,14 @@ assert( or mode == "world_edits" or mode == "npcs" or mode == "wild_battle" + or mode == "invalidate" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "wild_battle" then +if mode == "invalidate" then + dofile(codeRoot .. "/invalidate.lua").run(root, backend, version) +elseif mode == "wild_battle" then dofile(codeRoot .. "/wild_battle.lua").run(root, backend, version) elseif mode == "npcs" then dofile(codeRoot .. "/npcs.lua").run(root, backend, version) diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index 4c8f8ac29..5bc2d8895 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -134,6 +134,7 @@ do T.eq(type(world.party), "function", "early world method callable") T.eq(type(battle.snapshot), "function", "early battle method callable") for _, name in ipairs({ + "invalidateMap", "startWildBattle", "spawnNpc", "removeNpc", @@ -151,16 +152,6 @@ do }) do T.eq(m:supports("world", name), true, "implemented world method " .. name) end - for _, name in ipairs({ - "invalidateMap", - }) do - local available, why = m:supports("world", name) - T.eq(available, false, "no unverified native promise for " .. name) - T.check(type(why) == "string", "unavailable reason for " .. name) - local ok, reason = world[name](world, "bad") - T.eq(ok, nil, "unsupported method does not dispatch " .. name) - T.check(reason:find("unsupported:", 1, true), "explicit refusal for " .. name) - end T.eq(probes(), 0, "capability and unsupported calls do not resolve Game") T.eq(world:party(), nil, "no-game party refuses safely") T.eq(battle:snapshot(), nil, "no-game battle refuses safely") @@ -217,7 +208,7 @@ end for _, version in ipairs({ "platinum", "black" }) do local m = boot(3, nil, false, version) T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) - for _, name in ipairs({ "spawnNpc", "removeNpc", "npc" }) do + for _, name in ipairs({ "spawnNpc", "removeNpc", "npc", "startWildBattle", "invalidateMap" }) do T.eq(m:supports("world", name), false, "no shared NPC API promised for " .. version) end T.eq(m:supports("world", "warpTo"), false, "no shared warp promised for " .. version) diff --git a/tests/engine/api3_invalidate.lua b/tests/engine/api3_invalidate.lua new file mode 100644 index 000000000..ee295e14f --- /dev/null +++ b/tests/engine/api3_invalidate.lua @@ -0,0 +1,126 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.invalidate") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local OW = require("src.world.OverworldController") +local MapLoader = require("src.world.MapLoader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared invalidation contract: " .. tostring(why)) +end +for _, lock in ipairs({ "overlay", "moving", "script", "parallel", "link", "transition", "no_save" }) do + local f = fixture() + if lock == "overlay" then + f.game.stack.states[2] = {} + elseif lock == "moving" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + elseif lock == "parallel" then + f.ow.parallelQueue = { {} } + elseif lock == "link" then + f.game.linkSession = {} + elseif lock == "transition" then + f.ow.transitioning = true + else + f.game.save = nil + end + T.eq(f.exports.request(f.home), nil, lock .. " refuses reload") + T.eq(f.reloads, 0, lock .. " has no native effects") +end +for _, bad in ipairs({ {}, 1, 0 / 0, math.huge, false, string.rep("x", 257), "bad\0id" }) do + local f = fixture() + T.eq(f.exports.request(bad), nil, "malformed ID refused") + T.eq(f.reloads, 0, "malformed ID not dispatched") +end +do + local f = fixture() + f.reloadError = true + T.eq(f.exports.request(f.home), nil, "native failure returns refusal") + T.eq(#f.exports.events(), 0, "failed reload emits no success event") + f.reloadError = false + T.check(f.exports.request(f.home), "native exception releases guard") + local native = f.ow.reloadMap + function f.ow:reloadMap(...) + local value, why = f.exports.request(f.home) + T.eq(value, nil, "native callback cannot reenter") + T.eq(why, "world is busy", "reload reentry diagnosed") + return native(self, ...) + end + T.check(f.exports.request(f.home), "outer reload accepted") +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + if action == "rollback" then + f.loader:_rollback("invalidate") + elseif action == "reload" then + fixture() + else + Loader.teardown() + end + T.eq(f.exports.request(f.home), nil, "retired facade refuses after " .. action) + T.eq(f.reloads, 0, "retired facade makes no native calls") +end +local function upvalue(fn, wanted) + for i = 1, 100 do + local name, value = debug.getupvalue(fn, i) + if name == wanted then + return value, i + end + if not name then + break + end + end + error("native upvalue missing: " .. wanted) +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + for _, outside in ipairs({ false, true }) do + local f = fixture(version) + local old, index = upvalue(OW.reloadMap, "Game") + debug.setupvalue(OW.reloadMap, index, f.game) + f.ow.reloadMap = OW.reloadMap + f.ow.neighbors = { { map = { id = f.dest } } } + local cache = upvalue(MapLoader.invalidate, "cache") + cache[f.home], cache[f.dest] = {}, {} + local loads = {} + function f.ow:setMap(id, x, y, facing, opts) + loads[#loads + 1] = { id = id, x = x, y = y, facing = facing, opts = opts } + self.map = { + id = id, + def = f.game.data.maps[id], + inBounds = function() + return not outside + end, + } + end + function f.ow:healPoint() + return { map = f.dest, x = 1, y = 2 } + end + T.check(f.exports.request(f.home), "native reload accepted") + T.eq(cache[f.home], nil, "native target cache evicted") + T.eq(cache[f.dest], nil, "native neighbor cache evicted") + T.eq(loads[1].x, 0, "native reload preserves x") + T.eq(loads[1].facing, "left", "native reload preserves facing") + T.eq(loads[1].opts.keepMusic, true, "native seamless reload keeps music") + T.eq(loads[1].opts.seamless, true, "native seamless reload selected") + T.eq(#loads, outside and 2 or 1, "native out-of-bounds fallback count") + if outside then + T.eq(loads[2].id, f.dest, "invalidated geometry uses heal map") + T.eq(loads[2].facing, "down", "heal fallback orientation") + end + T.eq(#f.exports.events(), 1, "one native reload notification") + debug.setupvalue(OW.reloadMap, index, old) + end +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 map invalidation") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 98b44713d..290c3c3a8 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 569b78980a0586b1ed8ee9662e0a94da134f254a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:06:59 +0200 Subject: [PATCH 18/71] Map API 3 native flags to version-correct VM storage --- .github/workflows/api3-contract.yml | 5 + docs/api3-flags.md | 75 + docs/api3-portability.md | 10 +- docs/modding.md | 3 +- src/mods/Api3.lua | 41 +- src/mods/Api3Flags.lua | 188 +++ src/mods/flag_names/emerald.lua | 1545 ++++++++++++++++++ src/mods/flag_names/firered.lua | 1477 +++++++++++++++++ src/mods/flag_names/red.lua | 514 ++++++ src/mods/flag_names/yellow.lua | 529 ++++++ tests/api3_contract/flags.lua | 72 + tests/api3_contract/mods/flags/main.lua | 17 + tests/api3_contract/mods/flags/manifest.json | 1 + tests/api3_contract/worker.lua | 5 +- tests/engine/api3_compat.lua | 2 +- tests/engine/api3_flags.lua | 113 ++ tools/api3-contract-check/check.py | 2 +- tools/flag-catalog-check/generate.py | 83 + 18 files changed, 4634 insertions(+), 48 deletions(-) create mode 100644 docs/api3-flags.md create mode 100644 src/mods/Api3Flags.lua create mode 100644 src/mods/flag_names/emerald.lua create mode 100644 src/mods/flag_names/firered.lua create mode 100644 src/mods/flag_names/red.lua create mode 100644 src/mods/flag_names/yellow.lua create mode 100644 tests/api3_contract/flags.lua create mode 100644 tests/api3_contract/mods/flags/main.lua create mode 100644 tests/api3_contract/mods/flags/manifest.json create mode 100644 tests/engine/api3_flags.lua create mode 100644 tools/flag-catalog-check/generate.py diff --git a/.github/workflows/api3-contract.yml b/.github/workflows/api3-contract.yml index fe5b47952..f448f8484 100644 --- a/.github/workflows/api3-contract.yml +++ b/.github/workflows/api3-contract.yml @@ -7,6 +7,7 @@ on: - 'tests/api3_contract/**' - 'tests/engine/api3_*.lua' - 'tools/api3-contract-check/**' + - 'tools/flag-catalog-check/**' push: branches: [main, dev] paths: @@ -15,6 +16,7 @@ on: - 'tests/api3_contract/**' - 'tests/engine/api3_*.lua' - 'tools/api3-contract-check/**' + - 'tools/flag-catalog-check/**' permissions: contents: read concurrency: @@ -32,6 +34,7 @@ jobs: data tests tools/api3-contract-check + tools/flag-catalog-check - uses: actions/checkout@v7 with: repository: bryanthaboi/gen1recomp @@ -44,6 +47,8 @@ jobs: assets/fonts - name: install LuaJIT run: sudo apt-get update && sudo apt-get install -y luajit + - name: native flag catalog provenance + run: python3 gen2/tools/flag-catalog-check/generate.py --gen1 gen1-reference - name: identical mod contracts on both engines run: >- python3 gen2/tools/api3-contract-check/check.py diff --git a/docs/api3-flags.md b/docs/api3-flags.md new file mode 100644 index 000000000..3eea204a9 --- /dev/null +++ b/docs/api3-flags.md @@ -0,0 +1,75 @@ +# API-3 native and private flags + +`mod.world:getFlag(reference)` returns a boolean or `nil, reason`. +`setFlag(reference, boolean)` returns `true` or `nil, reason`. Real booleans are +required. The facade follows the current Game/save without bootstrapping one. +Flag reads/writes require a save but can run during menus/scripts; they do not +start an idle-world action. Writes from a rolled-back/reloaded/unloaded owner +are retired. Gen4/5 remain outside the shared API-3 world surface. + +## Private flags + +`mod:` and `mod..` address the same private flag in +`save.modData[modId].worldFlags`. A foreign `mod..*` reference is +refused. Keys are nonempty strings up to 256 bytes without control characters. +A read does not create storage. A clear removes the key. Malformed stores are +refused without overwriting them. The existing change-only `flag.changed` +payload remains `{name="mod..", value=boolean}`. + +## Cartridge flags + +| Version | Public references | Host storage | +| --- | --- | --- | +| Red/Blue | Known event symbol or its named bit index | Existing `EVENT_*` save.flags key | +| Yellow | Yellow event symbol or its named bit index | Existing `EVENT_*` save.flags key, with Yellow's own bit catalog | +| Gold/Silver | EVENT symbol or whole bit index 0–2047 | `EVENT_G2_%04d`, as used by the VM/save converter | +| Crystal | Whole bit index 0–2047 | Same native numbered namespace; symbolic references explicitly decline, matching the pinned Gen1 API | +| FireRed/Emerald | Known native flag symbol/alias, or a named numeric flag in that version's range | `FLAG_G3_%04X`, as used by the VM/save converter | + +Native IDs/symbols are cartridge-specific; no equivalence between the same bit +number in different games is invented. Unknown, fractional, non-finite, +negative, malformed or out-of-range references decline before writes. Special +variables are not a new flag namespace. Prism/Polished Crystal native mappings +are not guessed from Gold; private flags remain available there. + +Clearing a native host flag retains `false`, matching native object visibility's +distinction between "cleared" and "untouched". Gen3 synchronizes active object +templates carrying that exact native flag through the host's targeted native +visibility entry; unrelated actors/templates are left alone. Native failures +there can happen after the flag write and are not transactional rollback. + +Native `flag.changed` fires only on a true boolean transition. Its name is the +Gen1 symbol, Gen2 bit number, or Gen3 version-catalog name (falling back to the +numeric ID); Gen3 also provides `id`. For Gen3 cross-engine listeners, use +`event.id` for native identity: the pinned Gen1 implementation's native emitter +uses its base FireRed name catalog even on Emerald, so `event.name` is not a +portable label there. The shared fixture consumes `event.id or event.name` on +both engines. This preserves meaningful native identity rather than claiming +all label strings are identical. Reentrant writes from an API-3 flag event or +native visibility callback decline with "world is busy"; reads remain usable. + +## Catalog provenance and verification + +Generated Red/Yellow/FireRed/Emerald identifier catalogs contain names/numbers +only. No Gen1 engine implementation or ROM is vendored. Gold/Silver reuse the +host's existing Gen2Flags table. The reference is pinned to clean +`bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +```sh +luajit tests/engine/api3_flags.lua +python3 tools/flag-catalog-check/generate.py --gen1 /clean/pinned/gen1 --lua luajit +# Add --write to regenerate the deterministic catalogs. +``` + +The provenance checker verifies every generated row and all 1235 pinned Gold +symbol mappings against the host. CI checks provenance alongside the shared +mod suite. Eight identical public-only mod cases bring that suite to +72 comparisons / 144 isolated loader runs; compare preflight, numeric/symbolic +aliases, booleans, no-op event suppression, public event identity/value and +private ownership. The reference uses actual flag backends with synthetic +world/store setup. + +Local checks cover every shipped catalog alias/named numeric entry, the Gen3 +VM key conversion and all 2048 Gen2 bits, plus malformed inputs/stores, +owner retirement, reentry and scoped visibility dispatch. These are ROM-free +runtime/store tests, not cartridge story progression or visual gameplay tests. diff --git a/docs/api3-portability.md b/docs/api3-portability.md index f7ff88609..4a15a97c3 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -75,7 +75,7 @@ The common world calls currently enabled for generations 1–3 are: `effectiveEncounters`; - `canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, `canFly`, `flyTo`; -- `getFlag`/`setFlag` for private `mod:` boolean flags; +- [`getFlag`/`setFlag`](api3-flags.md) for private and validated native flags; - `queueScript` for the existing bounded portable script language; - [`warpTo`](api3-warp.md) for validated native map transitions; - [`toggleObject` and `replaceBlock`](api3-world-edits.md) for validated native @@ -88,8 +88,8 @@ The common world calls currently enabled for generations 1–3 are: Check `mod:supports("world", name)` before relying on a call. All 24 world method names in the pinned Gen1 API-3 facade now have host adapters for Gen1–3. This describes API availability, not identical native internals or full -cross-engine behavioral coverage. Native flag mappings and `mod.datasets:open` -remain unavailable; private mod flags are supported. Gen4/5 do not claim this +cross-engine behavioral coverage. Native flags have version-specific validated mappings; `mod.datasets:open` +remains unavailable. Gen4/5 do not claim this shared world surface. Private flags use `save.modData[modId].worldFlags`, independently of native @@ -134,8 +134,8 @@ The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. -Eight object/block cases and eight NPC cases extend this to 64 comparisons -and 128 isolated loader runs (including eight wild-battle and eight map-invalidation cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 72 comparisons +and 144 isolated loader runs (including wild-battle, map-invalidation and flag cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/modding.md b/docs/modding.md index e2838031f..79e0fb9cc 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -27,7 +27,8 @@ See [API-3 world warps](api3-warp.md) for validated map transitions and callback [API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes, [API-3 NPC actions](api3-npc.md) for runtime actors and movement handles, [API-3 wild encounters](api3-wild-battle.md) for native battle admission, -and [API-3 map invalidation](api3-invalidate-map.md) for native cache/reload. +[API-3 map invalidation](api3-invalidate-map.md) for native cache/reload, +and [API-3 flags](api3-flags.md) for validated native references and private flags. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index b64882e93..906983463 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -6,6 +6,7 @@ local Warp = require("src.mods.Api3Warp") local Edits = require("src.mods.Api3WorldEdits") local NPC = require("src.mods.Api3NPC") local Wild = require("src.mods.Api3WildBattle") +local Flags = require("src.mods.Api3Flags") local copy = require("src.mods.Merge").deepCopy local A = {} local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty @@ -115,46 +116,8 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) return Edits.call(game, native(game), gen, engineRequire, name, ...) end if name == "setFlag" or name == "getFlag" then - if not game.save then - return nil, "no save" - end local key, value = ... - if type(key) ~= "string" or key:sub(1, 4) ~= "mod:" then - return nil, "unsupported: API 3 native flag mapping" - end - if name == "setFlag" and type(value) ~= "boolean" then - return nil, "flag value must be boolean" - end - local id = key:sub(5) - if #id == 0 or #id > 256 or id:find("%c") then - return nil, "invalid mod flag" - end - local store = game.save.modData - if store ~= nil and type(store) ~= "table" then - return nil, "invalid mod save store" - end - local bucket = store and store[api.id] - if bucket ~= nil and type(bucket) ~= "table" then - return nil, "invalid mod save store" - end - local flags = bucket and bucket.worldFlags - if flags ~= nil and type(flags) ~= "table" then - return nil, "invalid mod flag store" - end - if name == "getFlag" then - return flags ~= nil and flags[id] == true - end - local before = flags ~= nil and flags[id] == true - game.save.modData = store or {} - bucket = bucket or {} - game.save.modData[api.id] = bucket - flags = flags or {} - bucket.worldFlags = flags - flags[id] = value or nil - if before ~= value then - Runtime.emit("flag.changed", { name = "mod." .. api.id .. "." .. id, value = value }) - end - return true + return Flags.call(game, api.id, gen, key, value, name == "setFlag", licensed, native) end if name == "queueScript" then return scripts:queueScript(...) diff --git a/src/mods/Api3Flags.lua b/src/mods/Api3Flags.lua new file mode 100644 index 000000000..d619025b2 --- /dev/null +++ b/src/mods/Api3Flags.lua @@ -0,0 +1,188 @@ +-- Portable references mapped to the flag stores the host VM/save converter use. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local F = {} +local catalogs, writing = {}, setmetatable({}, { __mode = "k" }) +local function integer(value, minimum, maximum) + return type(value) == "number" + and value == value + and value % 1 == 0 + and value >= minimum + and value <= maximum +end +local function identifier(value) + return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c") +end +local function catalog(version) + local key = version == "blue" and "red" or version + if catalogs[key] then + return catalogs[key] + end + local data = require("src.mods.flag_names." .. key) + local out = { names = {}, ids = {}, limit = data.limit } + for id, row in pairs(data.records) do + if row[1] then + out.names[id] = row[1] + end + for _, name in ipairs(row) do + if name then + out.ids[name] = id + end + end + end + catalogs[key] = out + return out +end +function F.resolve(generation, version, name) + local supported = ({ + red = 1, + blue = 1, + yellow = 1, + gold = 2, + silver = 2, + crystal = 2, + firered = 3, + emerald = 3, + })[version] + if supported ~= generation then + return nil, "native flag mapping unavailable for selected version" + end + if generation == 1 then + local t = catalog(version) + local id = integer(name, 0, 65535) and name or identifier(name) and t.ids[name] + if id ~= nil and t.names[id] then + return t.names[id], t.names[id], id + end + elseif generation == 2 then + local native = require("src.script.Gen2Flags") + local id = name + if identifier(name) then + if version == "crystal" then + return nil, "symbolic event flags unavailable for Crystal; use native ids" + end + id = native.EVENT_FLAG_INDEX[name] + end + if integer(id, 0, 2047) then + return native.eventFlag(id), id, id + end + elseif generation == 3 then + local t = catalog(version) + local id + if type(name) == "number" then + if integer(name, 1, t.limit - 1) and t.names[name] then + id = name + end + elseif identifier(name) then + id = t.ids[name] + end + if id and integer(id, 1, t.limit - 1) then + return ("FLAG_G3_%04X"):format(id), t.names[id] or id, id + end + end + return nil, "invalid or unknown flag" +end +function F.call(game, owner, gen, name, value, write, licensed, native) + if write and not licensed() then + return nil, "world facade is retired" + end + if write and type(value) ~= "boolean" then + return nil, "flag value must be boolean" + end + local save = game.save + if not save then + return nil, "no save" + end + local key + if type(name) == "string" then + if name:sub(1, 4) == "mod:" then + key = name:sub(5) + elseif name:sub(1, 4) == "mod." then + local prefix = "mod." .. owner .. "." + if name:sub(1, #prefix) ~= prefix then + return nil, "foreign mod flag" + end + key = name:sub(#prefix + 1) + end + end + if key then + if not identifier(key) then + return nil, "invalid mod flag" + end + local store = save.modData + if store ~= nil and type(store) ~= "table" then + return nil, "invalid mod save store" + end + local bucket = store and store[owner] + if bucket ~= nil and type(bucket) ~= "table" then + return nil, "invalid mod save store" + end + local flags = bucket and bucket.worldFlags + if flags ~= nil and type(flags) ~= "table" then + return nil, "invalid mod flag store" + end + if not write then + return flags ~= nil and flags[key] == true + end + if writing[save] then + return nil, "world is busy" + end + local before = flags ~= nil and flags[key] == true + save.modData = store or {} + bucket = bucket or {} + save.modData[owner] = bucket + bucket.worldFlags = flags or {} + bucket.worldFlags[key] = value or nil + if before ~= value then + writing[save] = true + local ok, err = + pcall(Runtime.emit, "flag.changed", { name = "mod." .. owner .. "." .. key, value = value }) + writing[save] = nil + if not ok then + error(err, 0) + end + end + return true + end + local storage, public, number = F.resolve(gen, Version.get(), name) + if not storage then + return nil, public + end + if type(save.flags) ~= "table" then + return nil, "invalid flag store" + end + if not write then + return save.flags[storage] == true + end + if writing[save] then + return nil, "world is busy" + end + writing[save] = true + local ok, err = pcall(function() + local before = save.flags[storage] == true + -- Native clear keeps false: visibility distinguishes cleared from untouched. + save.flags[storage] = value + if gen == 3 then + local ow = native(game):overworld() + if ow and ow.syncObjectVisibility then + for _, object in ipairs(ow.map and ow.map.def and ow.map.def.objects or {}) do + if object.eventFlag == storage then + ow:syncObjectVisibility(object) + end + end + end + end + if before ~= value then + local event = { name = public, value = value } + if gen == 3 then + event.id = number + end + Runtime.emit("flag.changed", event) + end + end) + writing[save] = nil + if not ok then + error(err, 0) + end + return true +end +return F diff --git a/src/mods/flag_names/emerald.lua b/src/mods/flag_names/emerald.lua new file mode 100644 index 000000000..815b89f80 --- /dev/null +++ b/src/mods/flag_names/emerald.lua @@ -0,0 +1,1545 @@ +-- Generated flag identifiers only; no engine implementation. +-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 +-- Regenerate with tools/flag-catalog-check/generate.py. +return { + limit = 2400, + records = { + [1] = { "FLAG_TEMP_1", "FLAG_TEMP_SKIP_GABBY_INTERVIEW", "TEMP_1", "TEMP_SKIP_GABBY_INTERVIEW" }, + [2] = { "FLAG_TEMP_2", "FLAG_TEMP_REGICE_PUZZLE_STARTED", "TEMP_2", "TEMP_REGICE_PUZZLE_STARTED" }, + [3] = { "FLAG_TEMP_3", "FLAG_TEMP_REGICE_PUZZLE_FAILED", "TEMP_3", "TEMP_REGICE_PUZZLE_FAILED" }, + [4] = { "FLAG_TEMP_4", "TEMP_4" }, + [5] = { "FLAG_TEMP_5", "TEMP_5" }, + [6] = { "FLAG_TEMP_6", "TEMP_6" }, + [7] = { "FLAG_TEMP_7", "TEMP_7" }, + [8] = { "FLAG_TEMP_8", "NUM_BADGES", "TEMP_8" }, + [9] = { "FLAG_TEMP_9", "TEMP_9" }, + [10] = { "FLAG_TEMP_A", "TEMP_A" }, + [11] = { "FLAG_TEMP_B", "TEMP_B" }, + [12] = { "FLAG_TEMP_C", "TEMP_C" }, + [13] = { "FLAG_TEMP_D", "TEMP_D" }, + [14] = { "FLAG_TEMP_E", "TEMP_E" }, + [15] = { "FLAG_TEMP_F", "TEMP_F" }, + [16] = { "FLAG_TEMP_10", "NUM_TEMP_VARS", "TEMP_10" }, + [17] = { "FLAG_TEMP_11", "FLAG_TEMP_HIDE_MIRAGE_ISLAND_BERRY_TREE", "TEMP_11", "TEMP_HIDE_MIRAGE_ISLAND_BERRY_TREE" }, + [18] = { "FLAG_TEMP_12", "TEMP_12" }, + [19] = { "FLAG_TEMP_13", "TEMP_13" }, + [20] = { "FLAG_TEMP_14", "NUM_WONDER_CARD_FLAGS", "TEMP_14" }, + [21] = { "FLAG_TEMP_15", "TEMP_15" }, + [22] = { "FLAG_TEMP_16", "TEMP_16" }, + [23] = { "FLAG_TEMP_17", "TEMP_17" }, + [24] = { "FLAG_TEMP_18", "TEMP_18" }, + [25] = { "FLAG_TEMP_19", "TEMP_19" }, + [26] = { "FLAG_TEMP_1A", "TEMP_1A" }, + [27] = { "FLAG_TEMP_1B", "TEMP_1B" }, + [28] = { "FLAG_TEMP_1C", "TEMP_1C" }, + [29] = { "FLAG_TEMP_1D", "TEMP_1D" }, + [30] = { "FLAG_TEMP_1E", "TEMP_1E" }, + [31] = { "FLAG_TEMP_1F", "TEMP_1F", "TEMP_FLAGS_END" }, + [32] = { "FLAG_UNUSED_0x020", "NUM_TEMP_FLAGS", "UNUSED_0x020" }, + [33] = { "FLAG_UNUSED_0x021", "UNUSED_0x021" }, + [34] = { "FLAG_UNUSED_0x022", "UNUSED_0x022" }, + [35] = { "FLAG_UNUSED_0x023", "UNUSED_0x023" }, + [36] = { "FLAG_UNUSED_0x024", "UNUSED_0x024" }, + [37] = { "FLAG_UNUSED_0x025", "UNUSED_0x025" }, + [38] = { "FLAG_UNUSED_0x026", "UNUSED_0x026" }, + [39] = { "FLAG_UNUSED_0x027", "UNUSED_0x027" }, + [40] = { "FLAG_UNUSED_0x028", "UNUSED_0x028" }, + [41] = { "FLAG_UNUSED_0x029", "UNUSED_0x029" }, + [42] = { "FLAG_UNUSED_0x02A", "UNUSED_0x02A" }, + [43] = { "FLAG_UNUSED_0x02B", "UNUSED_0x02B" }, + [44] = { "FLAG_UNUSED_0x02C", "UNUSED_0x02C" }, + [45] = { "FLAG_UNUSED_0x02D", "UNUSED_0x02D" }, + [46] = { "FLAG_UNUSED_0x02E", "UNUSED_0x02E" }, + [47] = { "FLAG_UNUSED_0x02F", "UNUSED_0x02F" }, + [48] = { "FLAG_UNUSED_0x030", "UNUSED_0x030" }, + [49] = { "FLAG_UNUSED_0x031", "UNUSED_0x031" }, + [50] = { "FLAG_UNUSED_0x032", "UNUSED_0x032" }, + [51] = { "FLAG_UNUSED_0x033", "UNUSED_0x033" }, + [52] = { "FLAG_UNUSED_0x034", "UNUSED_0x034" }, + [53] = { "FLAG_UNUSED_0x035", "UNUSED_0x035" }, + [54] = { "FLAG_UNUSED_0x036", "UNUSED_0x036" }, + [55] = { "FLAG_UNUSED_0x037", "UNUSED_0x037" }, + [56] = { "FLAG_UNUSED_0x038", "UNUSED_0x038" }, + [57] = { "FLAG_UNUSED_0x039", "UNUSED_0x039" }, + [58] = { "FLAG_UNUSED_0x03A", "UNUSED_0x03A" }, + [59] = { "FLAG_UNUSED_0x03B", "UNUSED_0x03B" }, + [60] = { "FLAG_UNUSED_0x03C", "UNUSED_0x03C" }, + [61] = { "FLAG_UNUSED_0x03D", "UNUSED_0x03D" }, + [62] = { "FLAG_UNUSED_0x03E", "UNUSED_0x03E" }, + [63] = { "FLAG_UNUSED_0x03F", "UNUSED_0x03F" }, + [64] = { "FLAG_UNUSED_0x040", "NUM_DAILY_FLAGS", "UNUSED_0x040" }, + [65] = { "FLAG_UNUSED_0x041", "UNUSED_0x041" }, + [66] = { "FLAG_UNUSED_0x042", "UNUSED_0x042" }, + [67] = { "FLAG_UNUSED_0x043", "UNUSED_0x043" }, + [68] = { "FLAG_UNUSED_0x044", "UNUSED_0x044" }, + [69] = { "FLAG_UNUSED_0x045", "UNUSED_0x045" }, + [70] = { "FLAG_UNUSED_0x046", "UNUSED_0x046" }, + [71] = { "FLAG_UNUSED_0x047", "UNUSED_0x047" }, + [72] = { "FLAG_UNUSED_0x048", "UNUSED_0x048" }, + [73] = { "FLAG_UNUSED_0x049", "UNUSED_0x049" }, + [74] = { "FLAG_UNUSED_0x04A", "UNUSED_0x04A" }, + [75] = { "FLAG_UNUSED_0x04B", "UNUSED_0x04B" }, + [76] = { "FLAG_UNUSED_0x04C", "UNUSED_0x04C" }, + [77] = { "FLAG_UNUSED_0x04D", "UNUSED_0x04D" }, + [78] = { "FLAG_UNUSED_0x04E", "UNUSED_0x04E" }, + [79] = { "FLAG_UNUSED_0x04F", "UNUSED_0x04F" }, + [80] = { "FLAG_HIDE_SKY_PILLAR_TOP_RAYQUAZA_STILL", "HIDE_SKY_PILLAR_TOP_RAYQUAZA_STILL" }, + [81] = { "FLAG_SET_WALL_CLOCK", "SET_WALL_CLOCK" }, + [82] = { "FLAG_RESCUED_BIRCH", "RESCUED_BIRCH" }, + [83] = { "FLAG_LEGENDARIES_IN_SOOTOPOLIS", "LEGENDARIES_IN_SOOTOPOLIS" }, + [84] = { "FLAG_UNUSED_0x054", "UNUSED_0x054" }, + [85] = { "FLAG_UNUSED_0x055", "UNUSED_0x055" }, + [86] = { "FLAG_HIDE_CONTEST_POKE_BALL", "HIDE_CONTEST_POKE_BALL" }, + [87] = { "FLAG_MET_RIVAL_MOM", "MET_RIVAL_MOM" }, + [88] = { "FLAG_BIRCH_AIDE_MET", "BIRCH_AIDE_MET" }, + [89] = { "FLAG_DECLINED_BIKE", "DECLINED_BIKE" }, + [90] = { "FLAG_RECEIVED_BIKE", "RECEIVED_BIKE" }, + [91] = { "FLAG_WATTSON_REMATCH_AVAILABLE", "WATTSON_REMATCH_AVAILABLE" }, + [92] = { "FLAG_COLLECTED_ALL_SILVER_SYMBOLS", "COLLECTED_ALL_SILVER_SYMBOLS" }, + [93] = { "FLAG_GOOD_LUCK_SAFARI_ZONE", "GOOD_LUCK_SAFARI_ZONE" }, + [94] = { "FLAG_RECEIVED_WAILMER_PAIL", "RECEIVED_WAILMER_PAIL" }, + [95] = { "FLAG_RECEIVED_POKEBLOCK_CASE", "RECEIVED_POKEBLOCK_CASE" }, + [96] = { "FLAG_RECEIVED_SECRET_POWER", "RECEIVED_SECRET_POWER" }, + [97] = { "FLAG_MET_TEAM_AQUA_HARBOR", "MET_TEAM_AQUA_HARBOR" }, + [98] = { "FLAG_TV_EXPLAINED", "TV_EXPLAINED" }, + [99] = { "FLAG_MAUVILLE_GYM_BARRIERS_STATE", "MAUVILLE_GYM_BARRIERS_STATE" }, + [100] = { "FLAG_MOSSDEEP_GYM_SWITCH_1", "MOSSDEEP_GYM_SWITCH_1" }, + [101] = { "FLAG_MOSSDEEP_GYM_SWITCH_2", "MOSSDEEP_GYM_SWITCH_2" }, + [102] = { "FLAG_MOSSDEEP_GYM_SWITCH_3", "MOSSDEEP_GYM_SWITCH_3" }, + [103] = { "FLAG_MOSSDEEP_GYM_SWITCH_4", "MOSSDEEP_GYM_SWITCH_4" }, + [104] = { "FLAG_UNUSED_0x068", "UNUSED_0x068" }, + [105] = { "FLAG_OCEANIC_MUSEUM_MET_REPORTER", "OCEANIC_MUSEUM_MET_REPORTER" }, + [106] = { "FLAG_RECEIVED_HM_STRENGTH", "RECEIVED_HM_STRENGTH" }, + [107] = { "FLAG_RECEIVED_HM_ROCK_SMASH", "RECEIVED_HM_ROCK_SMASH" }, + [108] = { "FLAG_WHITEOUT_TO_LAVARIDGE", "WHITEOUT_TO_LAVARIDGE" }, + [109] = { "FLAG_RECEIVED_HM_FLASH", "RECEIVED_HM_FLASH" }, + [110] = { "FLAG_RECEIVED_HM_FLY", "RECEIVED_HM_FLY" }, + [111] = { "FLAG_GROUDON_AWAKENED_MAGMA_HIDEOUT", "GROUDON_AWAKENED_MAGMA_HIDEOUT" }, + [112] = { "FLAG_TEAM_AQUA_ESCAPED_IN_SUBMARINE", "TEAM_AQUA_ESCAPED_IN_SUBMARINE" }, + [113] = { "FLAG_UNUSED_RS_LEGENDARY_BATTLE_DONE", "UNUSED_RS_LEGENDARY_BATTLE_DONE" }, + [114] = { "FLAG_SCOTT_CALL_BATTLE_FRONTIER", "SCOTT_CALL_BATTLE_FRONTIER" }, + [115] = { "FLAG_RECEIVED_METEORITE", "RECEIVED_METEORITE" }, + [116] = { "FLAG_ADVENTURE_STARTED", "ADVENTURE_STARTED" }, + [117] = { "FLAG_DEFEATED_MAGMA_SPACE_CENTER", "DEFEATED_MAGMA_SPACE_CENTER" }, + [118] = { "FLAG_MET_HIDDEN_POWER_GIVER", "MET_HIDDEN_POWER_GIVER" }, + [119] = { "FLAG_CANCEL_BATTLE_ROOM_CHALLENGE", "CANCEL_BATTLE_ROOM_CHALLENGE" }, + [120] = { "FLAG_LANDMARK_MIRAGE_TOWER", "LANDMARK_MIRAGE_TOWER" }, + [121] = { "FLAG_RECEIVED_TM_BRICK_BREAK", "RECEIVED_TM_BRICK_BREAK" }, + [122] = { "FLAG_RECEIVED_HM_SURF", "RECEIVED_HM_SURF" }, + [123] = { "FLAG_RECEIVED_HM_DIVE", "RECEIVED_HM_DIVE" }, + [124] = { "FLAG_REGISTER_RIVAL_POKENAV", "REGISTER_RIVAL_POKENAV" }, + [125] = { "FLAG_DEFEATED_RIVAL_ROUTE_104", "DEFEATED_RIVAL_ROUTE_104" }, + [126] = { "FLAG_DEFEATED_WALLY_VICTORY_ROAD", "DEFEATED_WALLY_VICTORY_ROAD" }, + [127] = { "FLAG_MET_PRETTY_PETAL_SHOP_OWNER", "MET_PRETTY_PETAL_SHOP_OWNER" }, + [128] = { "FLAG_ENABLE_ROXANNE_FIRST_CALL", "ENABLE_ROXANNE_FIRST_CALL", "NUM_SPECIAL_FLAGS" }, + [129] = { "FLAG_KYOGRE_ESCAPED_SEAFLOOR_CAVERN", "KYOGRE_ESCAPED_SEAFLOOR_CAVERN" }, + [130] = { "FLAG_DEFEATED_RIVAL_ROUTE103", "DEFEATED_RIVAL_ROUTE103" }, + [131] = { "FLAG_RECEIVED_DOLL_LANETTE", "RECEIVED_DOLL_LANETTE" }, + [132] = { "FLAG_RECEIVED_POTION_OLDALE", "RECEIVED_POTION_OLDALE" }, + [133] = { "FLAG_RECEIVED_AMULET_COIN", "RECEIVED_AMULET_COIN" }, + [134] = { "FLAG_PENDING_DAYCARE_EGG", "PENDING_DAYCARE_EGG" }, + [135] = { "FLAG_THANKED_FOR_PLAYING_WITH_WALLY", "THANKED_FOR_PLAYING_WITH_WALLY" }, + [136] = { "FLAG_ENABLE_FIRST_WALLY_POKENAV_CALL", "ENABLE_FIRST_WALLY_POKENAV_CALL" }, + [137] = { "FLAG_RECEIVED_HM_CUT", "RECEIVED_HM_CUT" }, + [138] = { "FLAG_SCOTT_CALL_FORTREE_GYM", "SCOTT_CALL_FORTREE_GYM" }, + [139] = { "FLAG_DEFEATED_EVIL_TEAM_MT_CHIMNEY", "DEFEATED_EVIL_TEAM_MT_CHIMNEY" }, + [140] = { "FLAG_RECEIVED_6_SODA_POP", "RECEIVED_6_SODA_POP" }, + [141] = { "FLAG_DEFEATED_SEASHORE_HOUSE", "DEFEATED_SEASHORE_HOUSE" }, + [142] = { "FLAG_DEVON_GOODS_STOLEN", "DEVON_GOODS_STOLEN" }, + [143] = { "FLAG_RECOVERED_DEVON_GOODS", "RECOVERED_DEVON_GOODS" }, + [144] = { "FLAG_RETURNED_DEVON_GOODS", "RETURNED_DEVON_GOODS" }, + [145] = { "FLAG_CAUGHT_LUGIA", "CAUGHT_LUGIA" }, + [146] = { "FLAG_CAUGHT_HO_OH", "CAUGHT_HO_OH" }, + [147] = { "FLAG_MR_BRINEY_SAILING_INTRO", "MR_BRINEY_SAILING_INTRO" }, + [148] = { "FLAG_DOCK_REJECTED_DEVON_GOODS", "DOCK_REJECTED_DEVON_GOODS" }, + [149] = { "FLAG_DELIVERED_DEVON_GOODS", "DELIVERED_DEVON_GOODS" }, + [150] = { "FLAG_RECEIVED_CONTEST_PASS", "RECEIVED_CONTEST_PASS" }, + [151] = { "FLAG_RECEIVED_CASTFORM", "RECEIVED_CASTFORM" }, + [152] = { "FLAG_RECEIVED_SUPER_ROD", "RECEIVED_SUPER_ROD" }, + [153] = { "FLAG_RUSTBORO_NPC_TRADE_COMPLETED", "RUSTBORO_NPC_TRADE_COMPLETED" }, + [154] = { "FLAG_PACIFIDLOG_NPC_TRADE_COMPLETED", "PACIFIDLOG_NPC_TRADE_COMPLETED" }, + [155] = { "FLAG_FORTREE_NPC_TRADE_COMPLETED", "FORTREE_NPC_TRADE_COMPLETED" }, + [156] = { "FLAG_BATTLE_FRONTIER_TRADE_DONE", "BATTLE_FRONTIER_TRADE_DONE" }, + [157] = { "FLAG_FORCE_MIRAGE_TOWER_VISIBLE", "FORCE_MIRAGE_TOWER_VISIBLE" }, + [158] = { "FLAG_SOOTOPOLIS_ARCHIE_MAXIE_LEAVE", "SOOTOPOLIS_ARCHIE_MAXIE_LEAVE" }, + [159] = { "FLAG_INTERACTED_WITH_DEVON_EMPLOYEE_GOODS_STOLEN", "INTERACTED_WITH_DEVON_EMPLOYEE_GOODS_STOLEN" }, + [160] = { "FLAG_COOL_PAINTING_MADE", "COOL_PAINTING_MADE" }, + [161] = { "FLAG_BEAUTY_PAINTING_MADE", "BEAUTY_PAINTING_MADE" }, + [162] = { "FLAG_CUTE_PAINTING_MADE", "CUTE_PAINTING_MADE" }, + [163] = { "FLAG_SMART_PAINTING_MADE", "SMART_PAINTING_MADE" }, + [164] = { "FLAG_TOUGH_PAINTING_MADE", "TOUGH_PAINTING_MADE" }, + [165] = { "FLAG_RECEIVED_TM_ROCK_TOMB", "RECEIVED_TM_ROCK_TOMB" }, + [166] = { "FLAG_RECEIVED_TM_BULK_UP", "RECEIVED_TM_BULK_UP" }, + [167] = { "FLAG_RECEIVED_TM_SHOCK_WAVE", "RECEIVED_TM_SHOCK_WAVE" }, + [168] = { "FLAG_RECEIVED_TM_OVERHEAT", "RECEIVED_TM_OVERHEAT" }, + [169] = { "FLAG_RECEIVED_TM_FACADE", "RECEIVED_TM_FACADE" }, + [170] = { "FLAG_RECEIVED_TM_AERIAL_ACE", "RECEIVED_TM_AERIAL_ACE" }, + [171] = { "FLAG_RECEIVED_TM_CALM_MIND", "RECEIVED_TM_CALM_MIND" }, + [172] = { "FLAG_RECEIVED_TM_WATER_PULSE", "RECEIVED_TM_WATER_PULSE" }, + [173] = { "FLAG_HIDE_SECRET_BASE_TRAINER", "HIDE_SECRET_BASE_TRAINER" }, + [174] = { "FLAG_DECORATION_1", "DECORATION_1" }, + [175] = { "FLAG_DECORATION_2", "DECORATION_2" }, + [176] = { "FLAG_DECORATION_3", "DECORATION_3" }, + [177] = { "FLAG_DECORATION_4", "DECORATION_4" }, + [178] = { "FLAG_DECORATION_5", "DECORATION_5" }, + [179] = { "FLAG_DECORATION_6", "DECORATION_6" }, + [180] = { "FLAG_DECORATION_7", "DECORATION_7" }, + [181] = { "FLAG_DECORATION_8", "DECORATION_8" }, + [182] = { "FLAG_DECORATION_9", "DECORATION_9" }, + [183] = { "FLAG_DECORATION_10", "DECORATION_10" }, + [184] = { "FLAG_DECORATION_11", "DECORATION_11" }, + [185] = { "FLAG_DECORATION_12", "DECORATION_12" }, + [186] = { "FLAG_DECORATION_13", "DECORATION_13" }, + [187] = { "FLAG_DECORATION_14", "DECORATION_14" }, + [188] = { "FLAG_RECEIVED_POKENAV", "RECEIVED_POKENAV" }, + [189] = { "FLAG_DELIVERED_STEVEN_LETTER", "DELIVERED_STEVEN_LETTER" }, + [190] = { "FLAG_DEFEATED_WALLY_MAUVILLE", "DEFEATED_WALLY_MAUVILLE" }, + [191] = { "FLAG_DEFEATED_GRUNT_SPACE_CENTER_1F", "DEFEATED_GRUNT_SPACE_CENTER_1F" }, + [192] = { "FLAG_RECEIVED_SUN_STONE_MOSSDEEP", "RECEIVED_SUN_STONE_MOSSDEEP" }, + [193] = { "FLAG_WALLY_SPEECH", "WALLY_SPEECH" }, + [194] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_1", "TRICK_HOUSE_PUZZLE_7_SWITCH_1" }, + [195] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_2", "TRICK_HOUSE_PUZZLE_7_SWITCH_2" }, + [196] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_3", "TRICK_HOUSE_PUZZLE_7_SWITCH_3" }, + [197] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_4", "TRICK_HOUSE_PUZZLE_7_SWITCH_4" }, + [198] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_5", "TRICK_HOUSE_PUZZLE_7_SWITCH_5" }, + [199] = { "FLAG_RUSTURF_TUNNEL_OPENED", "RUSTURF_TUNNEL_OPENED" }, + [200] = { "FLAG_RECEIVED_RED_SCARF", "RECEIVED_RED_SCARF" }, + [201] = { "FLAG_RECEIVED_BLUE_SCARF", "RECEIVED_BLUE_SCARF" }, + [202] = { "FLAG_RECEIVED_PINK_SCARF", "RECEIVED_PINK_SCARF" }, + [203] = { "FLAG_RECEIVED_GREEN_SCARF", "RECEIVED_GREEN_SCARF" }, + [204] = { "FLAG_RECEIVED_YELLOW_SCARF", "RECEIVED_YELLOW_SCARF" }, + [205] = { "FLAG_INTERACTED_WITH_STEVEN_SPACE_CENTER", "INTERACTED_WITH_STEVEN_SPACE_CENTER" }, + [206] = { "FLAG_ENCOUNTERED_LATIAS_OR_LATIOS", "ENCOUNTERED_LATIAS_OR_LATIOS" }, + [207] = { "FLAG_MET_ARCHIE_METEOR_FALLS", "MET_ARCHIE_METEOR_FALLS" }, + [208] = { "FLAG_GOT_BASEMENT_KEY_FROM_WATTSON", "GOT_BASEMENT_KEY_FROM_WATTSON" }, + [209] = { "FLAG_GOT_TM_THUNDERBOLT_FROM_WATTSON", "GOT_TM_THUNDERBOLT_FROM_WATTSON" }, + [210] = { "FLAG_FAN_CLUB_STRENGTH_SHARED", "FAN_CLUB_STRENGTH_SHARED" }, + [211] = { "FLAG_DEFEATED_RIVAL_RUSTBORO", "DEFEATED_RIVAL_RUSTBORO" }, + [212] = { "FLAG_RECEIVED_RED_OR_BLUE_ORB", "RECEIVED_RED_OR_BLUE_ORB" }, + [213] = { "FLAG_RECEIVED_PREMIER_BALL_RUSTBORO", "RECEIVED_PREMIER_BALL_RUSTBORO" }, + [214] = { "FLAG_ENABLE_WALLY_MATCH_CALL", "ENABLE_WALLY_MATCH_CALL" }, + [215] = { "FLAG_ENABLE_SCOTT_MATCH_CALL", "ENABLE_SCOTT_MATCH_CALL" }, + [216] = { "FLAG_ENABLE_MOM_MATCH_CALL", "ENABLE_MOM_MATCH_CALL" }, + [217] = { "FLAG_MET_DIVING_TREASURE_HUNTER", "MET_DIVING_TREASURE_HUNTER" }, + [218] = { "FLAG_MET_WAILMER_TRAINER", "MET_WAILMER_TRAINER" }, + [219] = { "FLAG_EVIL_LEADER_PLEASE_STOP", "EVIL_LEADER_PLEASE_STOP" }, + [220] = { "FLAG_NEVER_SET_0x0DC", "NEVER_SET_0x0DC" }, + [221] = { "FLAG_RECEIVED_GO_GOGGLES", "RECEIVED_GO_GOGGLES" }, + [222] = { "FLAG_WINGULL_SENT_ON_ERRAND", "WINGULL_SENT_ON_ERRAND" }, + [223] = { "FLAG_RECEIVED_MENTAL_HERB", "RECEIVED_MENTAL_HERB" }, + [224] = { "FLAG_WINGULL_DELIVERED_MAIL", "WINGULL_DELIVERED_MAIL" }, + [225] = { "FLAG_RECEIVED_20_COINS", "RECEIVED_20_COINS" }, + [226] = { "FLAG_RECEIVED_STARTER_DOLL", "RECEIVED_STARTER_DOLL" }, + [227] = { "FLAG_RECEIVED_GOOD_ROD", "RECEIVED_GOOD_ROD" }, + [228] = { "FLAG_REGI_DOORS_OPENED", "REGI_DOORS_OPENED" }, + [229] = { "FLAG_RECEIVED_TM_RETURN", "RECEIVED_TM_RETURN" }, + [230] = { "FLAG_RECEIVED_TM_SLUDGE_BOMB", "RECEIVED_TM_SLUDGE_BOMB" }, + [231] = { "FLAG_RECEIVED_TM_ROAR", "RECEIVED_TM_ROAR" }, + [232] = { "FLAG_RECEIVED_TM_GIGA_DRAIN", "RECEIVED_TM_GIGA_DRAIN" }, + [233] = { "FLAG_UNUSED_0x0E9", "UNUSED_0x0E9" }, + [234] = { "FLAG_RECEIVED_TM_REST", "RECEIVED_TM_REST" }, + [235] = { "FLAG_RECEIVED_TM_ATTRACT", "RECEIVED_TM_ATTRACT" }, + [236] = { "FLAG_RECEIVED_GLASS_ORNAMENT", "RECEIVED_GLASS_ORNAMENT" }, + [237] = { "FLAG_RECEIVED_SILVER_SHIELD", "RECEIVED_SILVER_SHIELD" }, + [238] = { "FLAG_RECEIVED_GOLD_SHIELD", "RECEIVED_GOLD_SHIELD" }, + [239] = { "FLAG_USED_STORAGE_KEY", "USED_STORAGE_KEY" }, + [240] = { "FLAG_USED_ROOM_1_KEY", "USED_ROOM_1_KEY" }, + [241] = { "FLAG_USED_ROOM_2_KEY", "USED_ROOM_2_KEY" }, + [242] = { "FLAG_USED_ROOM_4_KEY", "USED_ROOM_4_KEY" }, + [243] = { "FLAG_USED_ROOM_6_KEY", "USED_ROOM_6_KEY" }, + [244] = { "FLAG_MET_PROF_COZMO", "MET_PROF_COZMO" }, + [245] = { "FLAG_RECEIVED_WAILMER_DOLL", "RECEIVED_WAILMER_DOLL" }, + [246] = { "FLAG_RECEIVED_CHESTO_BERRY_ROUTE_104", "RECEIVED_CHESTO_BERRY_ROUTE_104" }, + [247] = { "FLAG_DEFEATED_SS_TIDAL_TRAINERS", "DEFEATED_SS_TIDAL_TRAINERS" }, + [248] = { "FLAG_RECEIVED_SPELON_BERRY", "RECEIVED_SPELON_BERRY" }, + [249] = { "FLAG_RECEIVED_PAMTRE_BERRY", "RECEIVED_PAMTRE_BERRY" }, + [250] = { "FLAG_RECEIVED_WATMEL_BERRY", "RECEIVED_WATMEL_BERRY" }, + [251] = { "FLAG_RECEIVED_DURIN_BERRY", "RECEIVED_DURIN_BERRY" }, + [252] = { "FLAG_RECEIVED_BELUE_BERRY", "RECEIVED_BELUE_BERRY" }, + [253] = { "FLAG_ENABLE_RIVAL_MATCH_CALL", "ENABLE_RIVAL_MATCH_CALL" }, + [254] = { "FLAG_RECEIVED_CHARCOAL", "RECEIVED_CHARCOAL" }, + [255] = { "FLAG_LATIOS_OR_LATIAS_ROAMING", "LATIOS_OR_LATIAS_ROAMING" }, + [256] = { "FLAG_RECEIVED_REPEAT_BALL", "RECEIVED_REPEAT_BALL", "VARS_COUNT" }, + [257] = { "FLAG_RECEIVED_OLD_ROD", "RECEIVED_OLD_ROD" }, + [258] = { "FLAG_RECEIVED_COIN_CASE", "RECEIVED_COIN_CASE" }, + [259] = { "FLAG_RETURNED_RED_OR_BLUE_ORB", "RETURNED_RED_OR_BLUE_ORB" }, + [260] = { "FLAG_RECEIVED_TM_SNATCH", "RECEIVED_TM_SNATCH" }, + [261] = { "FLAG_RECEIVED_TM_DIG", "RECEIVED_TM_DIG" }, + [262] = { "FLAG_RECEIVED_TM_BULLET_SEED", "RECEIVED_TM_BULLET_SEED" }, + [263] = { "FLAG_ENTERED_ELITE_FOUR", "ENTERED_ELITE_FOUR" }, + [264] = { "FLAG_RECEIVED_TM_HIDDEN_POWER", "RECEIVED_TM_HIDDEN_POWER" }, + [265] = { "FLAG_RECEIVED_TM_TORMENT", "RECEIVED_TM_TORMENT" }, + [266] = { "FLAG_RECEIVED_LAVARIDGE_EGG", "RECEIVED_LAVARIDGE_EGG" }, + [267] = { "FLAG_RECEIVED_REVIVED_FOSSIL_MON", "RECEIVED_REVIVED_FOSSIL_MON" }, + [268] = { "FLAG_SECRET_BASE_REGISTRY_ENABLED", "SECRET_BASE_REGISTRY_ENABLED" }, + [269] = { "FLAG_RECEIVED_TM_THIEF", "RECEIVED_TM_THIEF" }, + [270] = { "FLAG_CONTEST_SKETCH_CREATED", "CONTEST_SKETCH_CREATED" }, + [271] = { "FLAG_EVIL_TEAM_ESCAPED_STERN_SPOKE", "EVIL_TEAM_ESCAPED_STERN_SPOKE" }, + [272] = { "FLAG_RECEIVED_EXP_SHARE", "RECEIVED_EXP_SHARE" }, + [273] = { "FLAG_POKERUS_EXPLAINED", "POKERUS_EXPLAINED" }, + [274] = { "FLAG_RECEIVED_RUNNING_SHOES", "RECEIVED_RUNNING_SHOES" }, + [275] = { "FLAG_RECEIVED_QUICK_CLAW", "RECEIVED_QUICK_CLAW" }, + [276] = { "FLAG_RECEIVED_KINGS_ROCK", "RECEIVED_KINGS_ROCK" }, + [277] = { "FLAG_RECEIVED_MACHO_BRACE", "RECEIVED_MACHO_BRACE" }, + [278] = { "FLAG_RECEIVED_SOOTHE_BELL", "RECEIVED_SOOTHE_BELL" }, + [279] = { "FLAG_RECEIVED_WHITE_HERB", "RECEIVED_WHITE_HERB" }, + [280] = { "FLAG_RECEIVED_SOFT_SAND", "RECEIVED_SOFT_SAND" }, + [281] = { "FLAG_ENABLE_PROF_BIRCH_MATCH_CALL", "ENABLE_PROF_BIRCH_MATCH_CALL" }, + [282] = { "FLAG_RECEIVED_CLEANSE_TAG", "RECEIVED_CLEANSE_TAG" }, + [283] = { "FLAG_RECEIVED_FOCUS_BAND", "RECEIVED_FOCUS_BAND" }, + [284] = { "FLAG_DECLINED_WALLY_BATTLE_MAUVILLE", "DECLINED_WALLY_BATTLE_MAUVILLE" }, + [285] = { "FLAG_RECEIVED_DEVON_SCOPE", "RECEIVED_DEVON_SCOPE" }, + [286] = { "FLAG_DECLINED_RIVAL_BATTLE_LILYCOVE", "DECLINED_RIVAL_BATTLE_LILYCOVE" }, + [287] = { "FLAG_MET_DEVON_EMPLOYEE", "MET_DEVON_EMPLOYEE" }, + [288] = { "FLAG_MET_RIVAL_RUSTBORO", "MET_RIVAL_RUSTBORO" }, + [289] = { "FLAG_RECEIVED_SILK_SCARF", "RECEIVED_SILK_SCARF" }, + [290] = { "FLAG_NOT_READY_FOR_BATTLE_ROUTE_120", "NOT_READY_FOR_BATTLE_ROUTE_120" }, + [291] = { "FLAG_RECEIVED_SS_TICKET", "RECEIVED_SS_TICKET" }, + [292] = { "FLAG_MET_RIVAL_LILYCOVE", "MET_RIVAL_LILYCOVE" }, + [293] = { "FLAG_MET_RIVAL_IN_HOUSE_AFTER_LILYCOVE", "MET_RIVAL_IN_HOUSE_AFTER_LILYCOVE" }, + [294] = { "FLAG_EXCHANGED_SCANNER", "EXCHANGED_SCANNER" }, + [295] = { "FLAG_KECLEON_FLED_FORTREE", "KECLEON_FLED_FORTREE" }, + [296] = { "FLAG_PETALBURG_MART_EXPANDED_ITEMS", "PETALBURG_MART_EXPANDED_ITEMS" }, + [297] = { "FLAG_RECEIVED_MIRACLE_SEED", "RECEIVED_MIRACLE_SEED" }, + [298] = { "FLAG_RECEIVED_BELDUM", "RECEIVED_BELDUM" }, + [299] = { "FLAG_RECEIVED_FANCLUB_TM_THIS_WEEK", "RECEIVED_FANCLUB_TM_THIS_WEEK" }, + [300] = { "FLAG_MET_FANCLUB_YOUNGER_BROTHER", "MET_FANCLUB_YOUNGER_BROTHER" }, + [301] = { "FLAG_RIVAL_LEFT_FOR_ROUTE103", "RIVAL_LEFT_FOR_ROUTE103" }, + [302] = { "FLAG_OMIT_DIVE_FROM_STEVEN_LETTER", "OMIT_DIVE_FROM_STEVEN_LETTER" }, + [303] = { "FLAG_HAS_MATCH_CALL", "HAS_MATCH_CALL" }, + [304] = { "FLAG_ADDED_MATCH_CALL_TO_POKENAV", "ADDED_MATCH_CALL_TO_POKENAV" }, + [305] = { "FLAG_REGISTERED_STEVEN_POKENAV", "REGISTERED_STEVEN_POKENAV" }, + [306] = { "FLAG_ENABLE_NORMAN_MATCH_CALL", "ENABLE_NORMAN_MATCH_CALL" }, + [307] = { "FLAG_STEVEN_GUIDES_TO_CAVE_OF_ORIGIN", "STEVEN_GUIDES_TO_CAVE_OF_ORIGIN" }, + [308] = { "FLAG_MET_ARCHIE_SOOTOPOLIS", "MET_ARCHIE_SOOTOPOLIS" }, + [309] = { "FLAG_MET_MAXIE_SOOTOPOLIS", "MET_MAXIE_SOOTOPOLIS" }, + [310] = { "FLAG_MET_SCOTT_RUSTBORO", "MET_SCOTT_RUSTBORO" }, + [311] = { "FLAG_WALLACE_GOES_TO_SKY_PILLAR", "WALLACE_GOES_TO_SKY_PILLAR" }, + [312] = { "FLAG_RECEIVED_HM_WATERFALL", "RECEIVED_HM_WATERFALL" }, + [313] = { "FLAG_BEAT_MAGMA_GRUNT_JAGGED_PASS", "BEAT_MAGMA_GRUNT_JAGGED_PASS" }, + [314] = { "FLAG_RECEIVED_AURORA_TICKET", "RECEIVED_AURORA_TICKET" }, + [315] = { "FLAG_RECEIVED_MYSTIC_TICKET", "RECEIVED_MYSTIC_TICKET" }, + [316] = { "FLAG_RECEIVED_OLD_SEA_MAP", "RECEIVED_OLD_SEA_MAP" }, + [317] = { "FLAG_WONDER_CARD_UNUSED_1", "WONDER_CARD_UNUSED_1" }, + [318] = { "FLAG_WONDER_CARD_UNUSED_2", "WONDER_CARD_UNUSED_2" }, + [319] = { "FLAG_WONDER_CARD_UNUSED_3", "WONDER_CARD_UNUSED_3" }, + [320] = { "FLAG_WONDER_CARD_UNUSED_4", "WONDER_CARD_UNUSED_4" }, + [321] = { "FLAG_WONDER_CARD_UNUSED_5", "WONDER_CARD_UNUSED_5" }, + [322] = { "FLAG_WONDER_CARD_UNUSED_6", "WONDER_CARD_UNUSED_6" }, + [323] = { "FLAG_WONDER_CARD_UNUSED_7", "WONDER_CARD_UNUSED_7" }, + [324] = { "FLAG_WONDER_CARD_UNUSED_8", "WONDER_CARD_UNUSED_8" }, + [325] = { "FLAG_WONDER_CARD_UNUSED_9", "WONDER_CARD_UNUSED_9" }, + [326] = { "FLAG_WONDER_CARD_UNUSED_10", "WONDER_CARD_UNUSED_10" }, + [327] = { "FLAG_WONDER_CARD_UNUSED_11", "WONDER_CARD_UNUSED_11" }, + [328] = { "FLAG_WONDER_CARD_UNUSED_12", "WONDER_CARD_UNUSED_12" }, + [329] = { "FLAG_WONDER_CARD_UNUSED_13", "WONDER_CARD_UNUSED_13" }, + [330] = { "FLAG_WONDER_CARD_UNUSED_14", "WONDER_CARD_UNUSED_14" }, + [331] = { "FLAG_WONDER_CARD_UNUSED_15", "WONDER_CARD_UNUSED_15" }, + [332] = { "FLAG_WONDER_CARD_UNUSED_16", "WONDER_CARD_UNUSED_16" }, + [333] = { "FLAG_WONDER_CARD_UNUSED_17", "WONDER_CARD_UNUSED_17" }, + [334] = { "FLAG_MIRAGE_TOWER_VISIBLE", "MIRAGE_TOWER_VISIBLE" }, + [335] = { "FLAG_CHOSE_ROOT_FOSSIL", "CHOSE_ROOT_FOSSIL" }, + [336] = { "FLAG_CHOSE_CLAW_FOSSIL", "CHOSE_CLAW_FOSSIL" }, + [337] = { "FLAG_RECEIVED_POWDER_JAR", "RECEIVED_POWDER_JAR" }, + [338] = { "FLAG_CHOSEN_MULTI_BATTLE_NPC_PARTNER", "CHOSEN_MULTI_BATTLE_NPC_PARTNER" }, + [339] = { "FLAG_MET_BATTLE_FRONTIER_BREEDER", "MET_BATTLE_FRONTIER_BREEDER" }, + [340] = { "FLAG_MET_BATTLE_FRONTIER_MANIAC", "MET_BATTLE_FRONTIER_MANIAC" }, + [341] = { "FLAG_ENTERED_CONTEST", "ENTERED_CONTEST" }, + [342] = { "FLAG_MET_SLATEPORT_FANCLUB_CHAIRMAN", "MET_SLATEPORT_FANCLUB_CHAIRMAN" }, + [343] = { "FLAG_MET_BATTLE_FRONTIER_GAMBLER", "MET_BATTLE_FRONTIER_GAMBLER" }, + [344] = { "FLAG_ENABLE_MR_STONE_POKENAV", "ENABLE_MR_STONE_POKENAV" }, + [345] = { "FLAG_NURSE_MENTIONS_GOLD_CARD", "NURSE_MENTIONS_GOLD_CARD" }, + [346] = { "FLAG_MET_FRONTIER_BEAUTY_MOVE_TUTOR", "MET_FRONTIER_BEAUTY_MOVE_TUTOR" }, + [347] = { "FLAG_MET_FRONTIER_SWIMMER_MOVE_TUTOR", "MET_FRONTIER_SWIMMER_MOVE_TUTOR" }, + [348] = { "FLAG_REGISTERED_ROSE", "REGISTERED_ROSE", "TRAINER_REGISTERED_FLAGS_START" }, + [349] = { "FLAG_REGISTERED_ANDRES", "REGISTERED_ANDRES" }, + [350] = { "FLAG_REGISTERED_DUSTY", "REGISTERED_DUSTY" }, + [351] = { "FLAG_REGISTERED_LOLA", "REGISTERED_LOLA" }, + [352] = { "FLAG_REGISTERED_RICKY", "REGISTERED_RICKY" }, + [353] = { "FLAG_REGISTERED_LILA_AND_ROY", "REGISTERED_LILA_AND_ROY" }, + [354] = { "FLAG_REGISTERED_CRISTIN", "REGISTERED_CRISTIN" }, + [355] = { "FLAG_REGISTERED_BROOKE", "REGISTERED_BROOKE" }, + [356] = { "FLAG_REGISTERED_WILTON", "REGISTERED_WILTON" }, + [357] = { "FLAG_REGISTERED_VALERIE", "REGISTERED_VALERIE" }, + [358] = { "FLAG_REGISTERED_CINDY", "REGISTERED_CINDY" }, + [359] = { "FLAG_REGISTERED_THALIA", "REGISTERED_THALIA" }, + [360] = { "FLAG_REGISTERED_JESSICA", "REGISTERED_JESSICA" }, + [361] = { "FLAG_REGISTERED_WINSTON", "REGISTERED_WINSTON" }, + [362] = { "FLAG_REGISTERED_STEVE", "REGISTERED_STEVE" }, + [363] = { "FLAG_REGISTERED_TONY", "REGISTERED_TONY" }, + [364] = { "FLAG_REGISTERED_NOB", "REGISTERED_NOB" }, + [365] = { "FLAG_REGISTERED_KOJI", "REGISTERED_KOJI" }, + [366] = { "FLAG_REGISTERED_FERNANDO", "REGISTERED_FERNANDO" }, + [367] = { "FLAG_REGISTERED_DALTON", "REGISTERED_DALTON" }, + [368] = { "FLAG_REGISTERED_BERNIE", "REGISTERED_BERNIE" }, + [369] = { "FLAG_REGISTERED_ETHAN", "REGISTERED_ETHAN" }, + [370] = { "FLAG_REGISTERED_JOHN_AND_JAY", "REGISTERED_JOHN_AND_JAY" }, + [371] = { "FLAG_REGISTERED_JEFFREY", "REGISTERED_JEFFREY" }, + [372] = { "FLAG_REGISTERED_CAMERON", "REGISTERED_CAMERON" }, + [373] = { "FLAG_REGISTERED_JACKI", "REGISTERED_JACKI" }, + [374] = { "FLAG_REGISTERED_WALTER", "REGISTERED_WALTER" }, + [375] = { "FLAG_REGISTERED_KAREN", "REGISTERED_KAREN" }, + [376] = { "FLAG_REGISTERED_JERRY", "REGISTERED_JERRY" }, + [377] = { "FLAG_REGISTERED_ANNA_AND_MEG", "REGISTERED_ANNA_AND_MEG" }, + [378] = { "FLAG_REGISTERED_ISABEL", "REGISTERED_ISABEL" }, + [379] = { "FLAG_REGISTERED_MIGUEL", "REGISTERED_MIGUEL" }, + [380] = { "FLAG_REGISTERED_TIMOTHY", "REGISTERED_TIMOTHY" }, + [381] = { "FLAG_REGISTERED_SHELBY", "REGISTERED_SHELBY" }, + [382] = { "FLAG_REGISTERED_CALVIN", "REGISTERED_CALVIN" }, + [383] = { "FLAG_REGISTERED_ELLIOT", "REGISTERED_ELLIOT" }, + [384] = { "FLAG_REGISTERED_ISAIAH", "REGISTERED_ISAIAH" }, + [385] = { "FLAG_REGISTERED_MARIA", "REGISTERED_MARIA" }, + [386] = { "FLAG_REGISTERED_ABIGAIL", "REGISTERED_ABIGAIL" }, + [387] = { "FLAG_REGISTERED_DYLAN", "REGISTERED_DYLAN" }, + [388] = { "FLAG_REGISTERED_KATELYN", "REGISTERED_KATELYN" }, + [389] = { "FLAG_REGISTERED_BENJAMIN", "REGISTERED_BENJAMIN" }, + [390] = { "FLAG_REGISTERED_PABLO", "REGISTERED_PABLO" }, + [391] = { "FLAG_REGISTERED_NICOLAS", "REGISTERED_NICOLAS" }, + [392] = { "FLAG_REGISTERED_ROBERT", "REGISTERED_ROBERT" }, + [393] = { "FLAG_REGISTERED_LAO", "REGISTERED_LAO" }, + [394] = { "FLAG_REGISTERED_CYNDY", "REGISTERED_CYNDY" }, + [395] = { "FLAG_REGISTERED_MADELINE", "REGISTERED_MADELINE" }, + [396] = { "FLAG_REGISTERED_JENNY", "REGISTERED_JENNY" }, + [397] = { "FLAG_REGISTERED_DIANA", "REGISTERED_DIANA" }, + [398] = { "FLAG_REGISTERED_AMY_AND_LIV", "REGISTERED_AMY_AND_LIV" }, + [399] = { "FLAG_REGISTERED_ERNEST", "REGISTERED_ERNEST" }, + [400] = { "FLAG_REGISTERED_CORY", "REGISTERED_CORY" }, + [401] = { "FLAG_REGISTERED_EDWIN", "REGISTERED_EDWIN" }, + [402] = { "FLAG_REGISTERED_LYDIA", "REGISTERED_LYDIA" }, + [403] = { "FLAG_REGISTERED_ISAAC", "REGISTERED_ISAAC" }, + [404] = { "FLAG_REGISTERED_GABRIELLE", "REGISTERED_GABRIELLE" }, + [405] = { "FLAG_REGISTERED_CATHERINE", "REGISTERED_CATHERINE" }, + [406] = { "FLAG_REGISTERED_JACKSON", "REGISTERED_JACKSON" }, + [407] = { "FLAG_REGISTERED_HALEY", "REGISTERED_HALEY" }, + [408] = { "FLAG_REGISTERED_JAMES", "REGISTERED_JAMES" }, + [409] = { "FLAG_REGISTERED_TRENT", "REGISTERED_TRENT" }, + [410] = { "FLAG_REGISTERED_SAWYER", "REGISTERED_SAWYER" }, + [411] = { "FLAG_REGISTERED_KIRA_AND_DAN", "REGISTERED_KIRA_AND_DAN" }, + [412] = { "FLAG_REGISTERED_WALLY", "REGISTERED_WALLY" }, + [413] = { "FLAG_REGISTERED_ROXANNE", "REGISTERED_ROXANNE" }, + [414] = { "FLAG_REGISTERED_BRAWLY", "REGISTERED_BRAWLY" }, + [415] = { "FLAG_REGISTERED_WATTSON", "REGISTERED_WATTSON" }, + [416] = { "FLAG_REGISTERED_FLANNERY", "REGISTERED_FLANNERY" }, + [417] = { "FLAG_REGISTERED_NORMAN", "REGISTERED_NORMAN" }, + [418] = { "FLAG_REGISTERED_WINONA", "REGISTERED_WINONA" }, + [419] = { "FLAG_REGISTERED_TATE_AND_LIZA", "REGISTERED_TATE_AND_LIZA" }, + [420] = { "FLAG_REGISTERED_JUAN", "REGISTERED_JUAN" }, + [421] = { "FLAG_REGISTERED_SIDNEY", "REGISTERED_SIDNEY" }, + [422] = { "FLAG_REGISTERED_PHOEBE", "REGISTERED_PHOEBE" }, + [423] = { "FLAG_REGISTERED_GLACIA", "REGISTERED_GLACIA" }, + [424] = { "FLAG_REGISTERED_DRAKE", "REGISTERED_DRAKE" }, + [425] = { "FLAG_REGISTERED_WALLACE", "REGISTERED_WALLACE" }, + [426] = { "FLAG_UNUSED_0x1AA", "UNUSED_0x1AA" }, + [427] = { "FLAG_UNUSED_0x1AB", "UNUSED_0x1AB" }, + [428] = { "FLAG_DEFEATED_DEOXYS", "DEFEATED_DEOXYS" }, + [429] = { "FLAG_BATTLED_DEOXYS", "BATTLED_DEOXYS" }, + [430] = { "FLAG_SHOWN_EON_TICKET", "SHOWN_EON_TICKET" }, + [431] = { "FLAG_SHOWN_AURORA_TICKET", "SHOWN_AURORA_TICKET" }, + [432] = { "FLAG_SHOWN_OLD_SEA_MAP", "SHOWN_OLD_SEA_MAP" }, + [433] = { "FLAG_MOVE_TUTOR_TAUGHT_SWAGGER", "MOVE_TUTOR_TAUGHT_SWAGGER" }, + [434] = { "FLAG_MOVE_TUTOR_TAUGHT_ROLLOUT", "MOVE_TUTOR_TAUGHT_ROLLOUT" }, + [435] = { "FLAG_MOVE_TUTOR_TAUGHT_FURY_CUTTER", "MOVE_TUTOR_TAUGHT_FURY_CUTTER" }, + [436] = { "FLAG_MOVE_TUTOR_TAUGHT_MIMIC", "MOVE_TUTOR_TAUGHT_MIMIC" }, + [437] = { "FLAG_MOVE_TUTOR_TAUGHT_METRONOME", "MOVE_TUTOR_TAUGHT_METRONOME" }, + [438] = { "FLAG_MOVE_TUTOR_TAUGHT_SLEEP_TALK", "MOVE_TUTOR_TAUGHT_SLEEP_TALK" }, + [439] = { "FLAG_MOVE_TUTOR_TAUGHT_SUBSTITUTE", "MOVE_TUTOR_TAUGHT_SUBSTITUTE" }, + [440] = { "FLAG_MOVE_TUTOR_TAUGHT_DYNAMICPUNCH", "MOVE_TUTOR_TAUGHT_DYNAMICPUNCH" }, + [441] = { "FLAG_MOVE_TUTOR_TAUGHT_DOUBLE_EDGE", "MOVE_TUTOR_TAUGHT_DOUBLE_EDGE" }, + [442] = { "FLAG_MOVE_TUTOR_TAUGHT_EXPLOSION", "MOVE_TUTOR_TAUGHT_EXPLOSION" }, + [443] = { "FLAG_DEFEATED_REGIROCK", "DEFEATED_REGIROCK" }, + [444] = { "FLAG_DEFEATED_REGICE", "DEFEATED_REGICE" }, + [445] = { "FLAG_DEFEATED_REGISTEEL", "DEFEATED_REGISTEEL" }, + [446] = { "FLAG_DEFEATED_KYOGRE", "DEFEATED_KYOGRE" }, + [447] = { "FLAG_DEFEATED_GROUDON", "DEFEATED_GROUDON" }, + [448] = { "FLAG_DEFEATED_RAYQUAZA", "DEFEATED_RAYQUAZA" }, + [449] = { "FLAG_DEFEATED_VOLTORB_1_NEW_MAUVILLE", "DEFEATED_VOLTORB_1_NEW_MAUVILLE" }, + [450] = { "FLAG_DEFEATED_VOLTORB_2_NEW_MAUVILLE", "DEFEATED_VOLTORB_2_NEW_MAUVILLE" }, + [451] = { "FLAG_DEFEATED_VOLTORB_3_NEW_MAUVILLE", "DEFEATED_VOLTORB_3_NEW_MAUVILLE" }, + [452] = { "FLAG_DEFEATED_ELECTRODE_1_AQUA_HIDEOUT", "DEFEATED_ELECTRODE_1_AQUA_HIDEOUT" }, + [453] = { "FLAG_DEFEATED_ELECTRODE_2_AQUA_HIDEOUT", "DEFEATED_ELECTRODE_2_AQUA_HIDEOUT" }, + [454] = { "FLAG_DEFEATED_SUDOWOODO", "DEFEATED_SUDOWOODO" }, + [455] = { "FLAG_DEFEATED_MEW", "DEFEATED_MEW" }, + [456] = { "FLAG_DEFEATED_LATIAS_OR_LATIOS", "DEFEATED_LATIAS_OR_LATIOS" }, + [457] = { "FLAG_CAUGHT_LATIAS_OR_LATIOS", "CAUGHT_LATIAS_OR_LATIOS" }, + [458] = { "FLAG_CAUGHT_MEW", "CAUGHT_MEW" }, + [459] = { "FLAG_MET_SCOTT_AFTER_OBTAINING_STONE_BADGE", "MET_SCOTT_AFTER_OBTAINING_STONE_BADGE" }, + [460] = { "FLAG_MET_SCOTT_IN_VERDANTURF", "MET_SCOTT_IN_VERDANTURF" }, + [461] = { "FLAG_MET_SCOTT_IN_FALLARBOR", "MET_SCOTT_IN_FALLARBOR" }, + [462] = { "FLAG_MET_SCOTT_IN_LILYCOVE", "MET_SCOTT_IN_LILYCOVE" }, + [463] = { "FLAG_MET_SCOTT_IN_EVERGRANDE", "MET_SCOTT_IN_EVERGRANDE" }, + [464] = { "FLAG_MET_SCOTT_ON_SS_TIDAL", "MET_SCOTT_ON_SS_TIDAL" }, + [465] = { "FLAG_SCOTT_GIVES_BATTLE_POINTS", "SCOTT_GIVES_BATTLE_POINTS" }, + [466] = { "FLAG_COLLECTED_ALL_GOLD_SYMBOLS", "COLLECTED_ALL_GOLD_SYMBOLS" }, + [467] = { "FLAG_ENABLE_ROXANNE_MATCH_CALL", "ENABLE_ROXANNE_MATCH_CALL" }, + [468] = { "FLAG_ENABLE_BRAWLY_MATCH_CALL", "ENABLE_BRAWLY_MATCH_CALL" }, + [469] = { "FLAG_ENABLE_WATTSON_MATCH_CALL", "ENABLE_WATTSON_MATCH_CALL" }, + [470] = { "FLAG_ENABLE_FLANNERY_MATCH_CALL", "ENABLE_FLANNERY_MATCH_CALL" }, + [471] = { "FLAG_ENABLE_WINONA_MATCH_CALL", "ENABLE_WINONA_MATCH_CALL" }, + [472] = { "FLAG_ENABLE_TATE_AND_LIZA_MATCH_CALL", "ENABLE_TATE_AND_LIZA_MATCH_CALL" }, + [473] = { "FLAG_ENABLE_JUAN_MATCH_CALL", "ENABLE_JUAN_MATCH_CALL" }, + [474] = { "FLAG_UNUSED_0x1DA", "UNUSED_0x1DA" }, + [475] = { "FLAG_SHOWN_MYSTIC_TICKET", "SHOWN_MYSTIC_TICKET" }, + [476] = { "FLAG_DEFEATED_HO_OH", "DEFEATED_HO_OH" }, + [477] = { "FLAG_DEFEATED_LUGIA", "DEFEATED_LUGIA" }, + [478] = { "FLAG_UNUSED_0x1DE", "UNUSED_0x1DE" }, + [479] = { "FLAG_UNUSED_0x1DF", "UNUSED_0x1DF" }, + [480] = { "FLAG_UNUSED_0x1E0", "UNUSED_0x1E0" }, + [481] = { "FLAG_UNUSED_0x1E1", "UNUSED_0x1E1" }, + [482] = { "FLAG_UNUSED_0x1E2", "UNUSED_0x1E2" }, + [483] = { "FLAG_UNUSED_0x1E3", "UNUSED_0x1E3" }, + [484] = { "FLAG_MYSTERY_GIFT_DONE", "MYSTERY_GIFT_DONE" }, + [485] = { "FLAG_MYSTERY_GIFT_1", "MYSTERY_GIFT_1" }, + [486] = { "FLAG_MYSTERY_GIFT_2", "MYSTERY_GIFT_2" }, + [487] = { "FLAG_MYSTERY_GIFT_3", "MYSTERY_GIFT_3" }, + [488] = { "FLAG_MYSTERY_GIFT_4", "MYSTERY_GIFT_4" }, + [489] = { "FLAG_MYSTERY_GIFT_5", "MYSTERY_GIFT_5" }, + [490] = { "FLAG_MYSTERY_GIFT_6", "MYSTERY_GIFT_6" }, + [491] = { "FLAG_MYSTERY_GIFT_7", "MYSTERY_GIFT_7" }, + [492] = { "FLAG_MYSTERY_GIFT_8", "MYSTERY_GIFT_8" }, + [493] = { "FLAG_MYSTERY_GIFT_9", "MYSTERY_GIFT_9" }, + [494] = { "FLAG_MYSTERY_GIFT_10", "MYSTERY_GIFT_10" }, + [495] = { "FLAG_MYSTERY_GIFT_11", "MYSTERY_GIFT_11" }, + [496] = { "FLAG_MYSTERY_GIFT_12", "MYSTERY_GIFT_12" }, + [497] = { "FLAG_MYSTERY_GIFT_13", "MYSTERY_GIFT_13" }, + [498] = { "FLAG_MYSTERY_GIFT_14", "MYSTERY_GIFT_14" }, + [499] = { "FLAG_MYSTERY_GIFT_15", "MYSTERY_GIFT_15" }, + [500] = { "FLAG_HIDDEN_ITEM_LAVARIDGE_TOWN_ICE_HEAL", "FLAG_HIDDEN_ITEMS_START", "HIDDEN_ITEMS_START", "HIDDEN_ITEM_LAVARIDGE_TOWN_ICE_HEAL" }, + [501] = { "FLAG_HIDDEN_ITEM_TRICK_HOUSE_NUGGET", "HIDDEN_ITEM_TRICK_HOUSE_NUGGET" }, + [502] = { "FLAG_HIDDEN_ITEM_ROUTE_111_STARDUST", "HIDDEN_ITEM_ROUTE_111_STARDUST" }, + [503] = { "FLAG_HIDDEN_ITEM_ROUTE_113_ETHER", "HIDDEN_ITEM_ROUTE_113_ETHER" }, + [504] = { "FLAG_HIDDEN_ITEM_ROUTE_114_CARBOS", "HIDDEN_ITEM_ROUTE_114_CARBOS" }, + [505] = { "FLAG_HIDDEN_ITEM_ROUTE_119_CALCIUM", "HIDDEN_ITEM_ROUTE_119_CALCIUM" }, + [506] = { "FLAG_HIDDEN_ITEM_ROUTE_119_ULTRA_BALL", "HIDDEN_ITEM_ROUTE_119_ULTRA_BALL" }, + [507] = { "FLAG_HIDDEN_ITEM_ROUTE_123_SUPER_REPEL", "HIDDEN_ITEM_ROUTE_123_SUPER_REPEL" }, + [508] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_CARBOS", "HIDDEN_ITEM_UNDERWATER_124_CARBOS" }, + [509] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_GREEN_SHARD", "HIDDEN_ITEM_UNDERWATER_124_GREEN_SHARD" }, + [510] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_PEARL", "HIDDEN_ITEM_UNDERWATER_124_PEARL" }, + [511] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_BIG_PEARL", "HIDDEN_ITEM_UNDERWATER_124_BIG_PEARL" }, + [512] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_BLUE_SHARD", "HIDDEN_ITEM_UNDERWATER_126_BLUE_SHARD" }, + [513] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_1", "HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_1" }, + [514] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_HEART_SCALE", "HIDDEN_ITEM_UNDERWATER_126_HEART_SCALE" }, + [515] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_ULTRA_BALL", "HIDDEN_ITEM_UNDERWATER_126_ULTRA_BALL" }, + [516] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_STARDUST", "HIDDEN_ITEM_UNDERWATER_126_STARDUST" }, + [517] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_PEARL", "HIDDEN_ITEM_UNDERWATER_126_PEARL" }, + [518] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_YELLOW_SHARD", "HIDDEN_ITEM_UNDERWATER_126_YELLOW_SHARD" }, + [519] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_IRON", "HIDDEN_ITEM_UNDERWATER_126_IRON" }, + [520] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_BIG_PEARL", "HIDDEN_ITEM_UNDERWATER_126_BIG_PEARL" }, + [521] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_STAR_PIECE", "HIDDEN_ITEM_UNDERWATER_127_STAR_PIECE" }, + [522] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_HP_UP", "HIDDEN_ITEM_UNDERWATER_127_HP_UP" }, + [523] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_HEART_SCALE", "HIDDEN_ITEM_UNDERWATER_127_HEART_SCALE" }, + [524] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_RED_SHARD", "HIDDEN_ITEM_UNDERWATER_127_RED_SHARD" }, + [525] = { "FLAG_HIDDEN_ITEM_UNDERWATER_128_PROTEIN", "HIDDEN_ITEM_UNDERWATER_128_PROTEIN" }, + [526] = { "FLAG_HIDDEN_ITEM_UNDERWATER_128_PEARL", "HIDDEN_ITEM_UNDERWATER_128_PEARL" }, + [527] = { "FLAG_HIDDEN_ITEM_LILYCOVE_CITY_HEART_SCALE", "HIDDEN_ITEM_LILYCOVE_CITY_HEART_SCALE" }, + [528] = { "FLAG_HIDDEN_ITEM_FALLARBOR_TOWN_NUGGET", "HIDDEN_ITEM_FALLARBOR_TOWN_NUGGET" }, + [529] = { "FLAG_HIDDEN_ITEM_MT_PYRE_EXTERIOR_ULTRA_BALL", "HIDDEN_ITEM_MT_PYRE_EXTERIOR_ULTRA_BALL" }, + [530] = { "FLAG_HIDDEN_ITEM_ROUTE_113_TM_DOUBLE_TEAM", "HIDDEN_ITEM_ROUTE_113_TM_DOUBLE_TEAM" }, + [531] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_1_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_1_KEY" }, + [532] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_2_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_2_KEY" }, + [533] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_4_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_4_KEY" }, + [534] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_6_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_6_KEY" }, + [535] = { "FLAG_HIDDEN_ITEM_SS_TIDAL_LOWER_DECK_LEFTOVERS", "HIDDEN_ITEM_SS_TIDAL_LOWER_DECK_LEFTOVERS" }, + [536] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_CALCIUM", "HIDDEN_ITEM_UNDERWATER_124_CALCIUM" }, + [537] = { "FLAG_HIDDEN_ITEM_ROUTE_104_POTION", "HIDDEN_ITEM_ROUTE_104_POTION" }, + [538] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_2", "HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_2" }, + [539] = { "FLAG_HIDDEN_ITEM_ROUTE_121_HP_UP", "HIDDEN_ITEM_ROUTE_121_HP_UP" }, + [540] = { "FLAG_HIDDEN_ITEM_ROUTE_121_NUGGET", "HIDDEN_ITEM_ROUTE_121_NUGGET" }, + [541] = { "FLAG_HIDDEN_ITEM_ROUTE_123_REVIVE", "HIDDEN_ITEM_ROUTE_123_REVIVE" }, + [542] = { "FLAG_HIDDEN_ITEM_ROUTE_114_REVIVE", "HIDDEN_ITEM_ROUTE_114_REVIVE" }, + [543] = { "FLAG_HIDDEN_ITEM_LILYCOVE_CITY_PP_UP", "HIDDEN_ITEM_LILYCOVE_CITY_PP_UP" }, + [544] = { "FLAG_HIDDEN_ITEM_ROUTE_104_SUPER_POTION", "HIDDEN_ITEM_ROUTE_104_SUPER_POTION" }, + [545] = { "FLAG_HIDDEN_ITEM_ROUTE_116_SUPER_POTION", "HIDDEN_ITEM_ROUTE_116_SUPER_POTION" }, + [546] = { "FLAG_HIDDEN_ITEM_ROUTE_106_STARDUST", "HIDDEN_ITEM_ROUTE_106_STARDUST" }, + [547] = { "FLAG_HIDDEN_ITEM_ROUTE_106_HEART_SCALE", "HIDDEN_ITEM_ROUTE_106_HEART_SCALE" }, + [548] = { "FLAG_HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_1", "HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_1" }, + [549] = { "FLAG_HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_2", "HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_2" }, + [550] = { "FLAG_HIDDEN_ITEM_ROUTE_109_REVIVE", "HIDDEN_ITEM_ROUTE_109_REVIVE" }, + [551] = { "FLAG_HIDDEN_ITEM_ROUTE_109_GREAT_BALL", "HIDDEN_ITEM_ROUTE_109_GREAT_BALL" }, + [552] = { "FLAG_HIDDEN_ITEM_ROUTE_109_HEART_SCALE_1", "HIDDEN_ITEM_ROUTE_109_HEART_SCALE_1" }, + [553] = { "FLAG_HIDDEN_ITEM_ROUTE_110_GREAT_BALL", "HIDDEN_ITEM_ROUTE_110_GREAT_BALL" }, + [554] = { "FLAG_HIDDEN_ITEM_ROUTE_110_REVIVE", "HIDDEN_ITEM_ROUTE_110_REVIVE" }, + [555] = { "FLAG_HIDDEN_ITEM_ROUTE_110_FULL_HEAL", "HIDDEN_ITEM_ROUTE_110_FULL_HEAL" }, + [556] = { "FLAG_HIDDEN_ITEM_ROUTE_111_PROTEIN", "HIDDEN_ITEM_ROUTE_111_PROTEIN" }, + [557] = { "FLAG_HIDDEN_ITEM_ROUTE_111_RARE_CANDY", "HIDDEN_ITEM_ROUTE_111_RARE_CANDY" }, + [558] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_POTION", "HIDDEN_ITEM_PETALBURG_WOODS_POTION" }, + [559] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_1", "HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_1" }, + [560] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_2", "HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_2" }, + [561] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_POKE_BALL", "HIDDEN_ITEM_PETALBURG_WOODS_POKE_BALL" }, + [562] = { "FLAG_HIDDEN_ITEM_ROUTE_104_POKE_BALL", "HIDDEN_ITEM_ROUTE_104_POKE_BALL" }, + [563] = { "FLAG_HIDDEN_ITEM_ROUTE_106_POKE_BALL", "HIDDEN_ITEM_ROUTE_106_POKE_BALL" }, + [564] = { "FLAG_HIDDEN_ITEM_ROUTE_109_ETHER", "HIDDEN_ITEM_ROUTE_109_ETHER" }, + [565] = { "FLAG_HIDDEN_ITEM_ROUTE_110_POKE_BALL", "HIDDEN_ITEM_ROUTE_110_POKE_BALL" }, + [566] = { "FLAG_HIDDEN_ITEM_ROUTE_118_HEART_SCALE", "HIDDEN_ITEM_ROUTE_118_HEART_SCALE" }, + [567] = { "FLAG_HIDDEN_ITEM_ROUTE_118_IRON", "HIDDEN_ITEM_ROUTE_118_IRON" }, + [568] = { "FLAG_HIDDEN_ITEM_ROUTE_119_FULL_HEAL", "HIDDEN_ITEM_ROUTE_119_FULL_HEAL" }, + [569] = { "FLAG_HIDDEN_ITEM_ROUTE_120_RARE_CANDY_2", "HIDDEN_ITEM_ROUTE_120_RARE_CANDY_2" }, + [570] = { "FLAG_HIDDEN_ITEM_ROUTE_120_ZINC", "HIDDEN_ITEM_ROUTE_120_ZINC" }, + [571] = { "FLAG_HIDDEN_ITEM_ROUTE_120_RARE_CANDY_1", "HIDDEN_ITEM_ROUTE_120_RARE_CANDY_1" }, + [572] = { "FLAG_HIDDEN_ITEM_ROUTE_117_REPEL", "HIDDEN_ITEM_ROUTE_117_REPEL" }, + [573] = { "FLAG_HIDDEN_ITEM_ROUTE_121_FULL_HEAL", "HIDDEN_ITEM_ROUTE_121_FULL_HEAL" }, + [574] = { "FLAG_HIDDEN_ITEM_ROUTE_123_HYPER_POTION", "HIDDEN_ITEM_ROUTE_123_HYPER_POTION" }, + [575] = { "FLAG_HIDDEN_ITEM_LILYCOVE_CITY_POKE_BALL", "HIDDEN_ITEM_LILYCOVE_CITY_POKE_BALL" }, + [576] = { "FLAG_HIDDEN_ITEM_JAGGED_PASS_GREAT_BALL", "HIDDEN_ITEM_JAGGED_PASS_GREAT_BALL" }, + [577] = { "FLAG_HIDDEN_ITEM_JAGGED_PASS_FULL_HEAL", "HIDDEN_ITEM_JAGGED_PASS_FULL_HEAL" }, + [578] = { "FLAG_HIDDEN_ITEM_MT_PYRE_EXTERIOR_MAX_ETHER", "HIDDEN_ITEM_MT_PYRE_EXTERIOR_MAX_ETHER" }, + [579] = { "FLAG_HIDDEN_ITEM_MT_PYRE_SUMMIT_ZINC", "HIDDEN_ITEM_MT_PYRE_SUMMIT_ZINC" }, + [580] = { "FLAG_HIDDEN_ITEM_MT_PYRE_SUMMIT_RARE_CANDY", "HIDDEN_ITEM_MT_PYRE_SUMMIT_RARE_CANDY" }, + [581] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL", "HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL" }, + [582] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_B2F_ELIXIR", "HIDDEN_ITEM_VICTORY_ROAD_B2F_ELIXIR" }, + [583] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_B2F_MAX_REPEL", "HIDDEN_ITEM_VICTORY_ROAD_B2F_MAX_REPEL" }, + [584] = { "FLAG_HIDDEN_ITEM_ROUTE_120_REVIVE", "HIDDEN_ITEM_ROUTE_120_REVIVE" }, + [585] = { "FLAG_HIDDEN_ITEM_ROUTE_104_ANTIDOTE", "HIDDEN_ITEM_ROUTE_104_ANTIDOTE" }, + [586] = { "FLAG_HIDDEN_ITEM_ROUTE_108_RARE_CANDY", "HIDDEN_ITEM_ROUTE_108_RARE_CANDY" }, + [587] = { "FLAG_HIDDEN_ITEM_ROUTE_119_MAX_ETHER", "HIDDEN_ITEM_ROUTE_119_MAX_ETHER" }, + [588] = { "FLAG_HIDDEN_ITEM_ROUTE_104_HEART_SCALE", "HIDDEN_ITEM_ROUTE_104_HEART_SCALE" }, + [589] = { "FLAG_HIDDEN_ITEM_ROUTE_105_HEART_SCALE", "HIDDEN_ITEM_ROUTE_105_HEART_SCALE" }, + [590] = { "FLAG_HIDDEN_ITEM_ROUTE_109_HEART_SCALE_2", "HIDDEN_ITEM_ROUTE_109_HEART_SCALE_2" }, + [591] = { "FLAG_HIDDEN_ITEM_ROUTE_109_HEART_SCALE_3", "HIDDEN_ITEM_ROUTE_109_HEART_SCALE_3" }, + [592] = { "FLAG_HIDDEN_ITEM_ROUTE_128_HEART_SCALE_1", "HIDDEN_ITEM_ROUTE_128_HEART_SCALE_1" }, + [593] = { "FLAG_HIDDEN_ITEM_ROUTE_128_HEART_SCALE_2", "HIDDEN_ITEM_ROUTE_128_HEART_SCALE_2" }, + [594] = { "FLAG_HIDDEN_ITEM_ROUTE_128_HEART_SCALE_3", "HIDDEN_ITEM_ROUTE_128_HEART_SCALE_3" }, + [595] = { "FLAG_HIDDEN_ITEM_PETALBURG_CITY_RARE_CANDY", "HIDDEN_ITEM_PETALBURG_CITY_RARE_CANDY" }, + [596] = { "FLAG_HIDDEN_ITEM_ROUTE_116_BLACK_GLASSES", "HIDDEN_ITEM_ROUTE_116_BLACK_GLASSES" }, + [597] = { "FLAG_HIDDEN_ITEM_ROUTE_115_HEART_SCALE", "HIDDEN_ITEM_ROUTE_115_HEART_SCALE" }, + [598] = { "FLAG_HIDDEN_ITEM_ROUTE_113_NUGGET", "HIDDEN_ITEM_ROUTE_113_NUGGET" }, + [599] = { "FLAG_HIDDEN_ITEM_ROUTE_123_PP_UP", "HIDDEN_ITEM_ROUTE_123_PP_UP" }, + [600] = { "FLAG_HIDDEN_ITEM_ROUTE_121_MAX_REVIVE", "HIDDEN_ITEM_ROUTE_121_MAX_REVIVE" }, + [601] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_CALCIUM", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_CALCIUM" }, + [602] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_ZINC", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_ZINC" }, + [603] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_PROTEIN", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_PROTEIN" }, + [604] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_IRON", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_IRON" }, + [605] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_FULL_RESTORE", "HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_FULL_RESTORE" }, + [606] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_RARE_CANDY", "HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_RARE_CANDY" }, + [607] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_ZINC", "HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_ZINC" }, + [608] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_PP_UP", "HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_PP_UP" }, + [609] = { "FLAG_HIDDEN_ITEM_NAVEL_ROCK_TOP_SACRED_ASH", "HIDDEN_ITEM_NAVEL_ROCK_TOP_SACRED_ASH" }, + [610] = { "FLAG_HIDDEN_ITEM_ROUTE_123_RARE_CANDY", "HIDDEN_ITEM_ROUTE_123_RARE_CANDY" }, + [611] = { "FLAG_HIDDEN_ITEM_ROUTE_105_BIG_PEARL", "HIDDEN_ITEM_ROUTE_105_BIG_PEARL" }, + [612] = { "FLAG_UNUSED_0x264", "UNUSED_0x264" }, + [613] = { "FLAG_UNUSED_0x265", "UNUSED_0x265" }, + [614] = { "FLAG_UNUSED_0x266", "UNUSED_0x266" }, + [615] = { "FLAG_UNUSED_0x267", "UNUSED_0x267" }, + [616] = { "FLAG_UNUSED_0x268", "UNUSED_0x268" }, + [617] = { "FLAG_UNUSED_0x269", "UNUSED_0x269" }, + [618] = { "FLAG_UNUSED_0x26A", "UNUSED_0x26A" }, + [619] = { "FLAG_UNUSED_0x26B", "UNUSED_0x26B" }, + [620] = { "FLAG_UNUSED_0x26C", "UNUSED_0x26C" }, + [621] = { "FLAG_UNUSED_0x26D", "UNUSED_0x26D" }, + [622] = { "FLAG_UNUSED_0x26E", "UNUSED_0x26E" }, + [623] = { "FLAG_UNUSED_0x26F", "UNUSED_0x26F" }, + [624] = { "FLAG_UNUSED_0x270", "UNUSED_0x270" }, + [625] = { "FLAG_UNUSED_0x271", "UNUSED_0x271" }, + [626] = { "FLAG_UNUSED_0x272", "UNUSED_0x272" }, + [627] = { "FLAG_UNUSED_0x273", "UNUSED_0x273" }, + [628] = { "FLAG_UNUSED_0x274", "UNUSED_0x274" }, + [629] = { "FLAG_UNUSED_0x275", "UNUSED_0x275" }, + [630] = { "FLAG_UNUSED_0x276", "UNUSED_0x276" }, + [631] = { "FLAG_UNUSED_0x277", "UNUSED_0x277" }, + [632] = { "FLAG_UNUSED_0x278", "UNUSED_0x278" }, + [633] = { "FLAG_UNUSED_0x279", "UNUSED_0x279" }, + [634] = { "FLAG_UNUSED_0x27A", "UNUSED_0x27A" }, + [635] = { "FLAG_UNUSED_0x27B", "UNUSED_0x27B" }, + [636] = { "FLAG_UNUSED_0x27C", "UNUSED_0x27C" }, + [637] = { "FLAG_UNUSED_0x27D", "UNUSED_0x27D" }, + [638] = { "FLAG_UNUSED_0x27E", "UNUSED_0x27E" }, + [639] = { "FLAG_UNUSED_0x27F", "UNUSED_0x27F" }, + [640] = { "FLAG_UNUSED_0x280", "UNUSED_0x280" }, + [641] = { "FLAG_UNUSED_0x281", "UNUSED_0x281" }, + [642] = { "FLAG_UNUSED_0x282", "UNUSED_0x282" }, + [643] = { "FLAG_UNUSED_0x283", "UNUSED_0x283" }, + [644] = { "FLAG_UNUSED_0x284", "UNUSED_0x284" }, + [645] = { "FLAG_UNUSED_0x285", "UNUSED_0x285" }, + [646] = { "FLAG_UNUSED_0x286", "UNUSED_0x286" }, + [647] = { "FLAG_UNUSED_0x287", "UNUSED_0x287" }, + [648] = { "FLAG_UNUSED_0x288", "UNUSED_0x288" }, + [649] = { "FLAG_UNUSED_0x289", "UNUSED_0x289" }, + [650] = { "FLAG_UNUSED_0x28A", "UNUSED_0x28A" }, + [651] = { "FLAG_UNUSED_0x28B", "UNUSED_0x28B" }, + [652] = { "FLAG_UNUSED_0x28C", "UNUSED_0x28C" }, + [653] = { "FLAG_UNUSED_0x28D", "UNUSED_0x28D" }, + [654] = { "FLAG_UNUSED_0x28E", "UNUSED_0x28E" }, + [655] = { "FLAG_UNUSED_0x28F", "UNUSED_0x28F" }, + [656] = { "FLAG_UNUSED_0x290", "UNUSED_0x290" }, + [657] = { "FLAG_UNUSED_0x291", "UNUSED_0x291" }, + [658] = { "FLAG_UNUSED_0x292", "UNUSED_0x292" }, + [659] = { "FLAG_UNUSED_0x293", "UNUSED_0x293" }, + [660] = { "FLAG_UNUSED_0x294", "UNUSED_0x294" }, + [661] = { "FLAG_UNUSED_0x295", "UNUSED_0x295" }, + [662] = { "FLAG_UNUSED_0x296", "UNUSED_0x296" }, + [663] = { "FLAG_UNUSED_0x297", "UNUSED_0x297" }, + [664] = { "FLAG_UNUSED_0x298", "UNUSED_0x298" }, + [665] = { "FLAG_UNUSED_0x299", "UNUSED_0x299" }, + [666] = { "FLAG_UNUSED_0x29A", "UNUSED_0x29A" }, + [667] = { "FLAG_UNUSED_0x29B", "UNUSED_0x29B" }, + [668] = { "FLAG_UNUSED_0x29C", "UNUSED_0x29C" }, + [669] = { "FLAG_UNUSED_0x29D", "UNUSED_0x29D" }, + [670] = { "FLAG_UNUSED_0x29E", "UNUSED_0x29E" }, + [671] = { "FLAG_UNUSED_0x29F", "UNUSED_0x29F" }, + [672] = { "FLAG_UNUSED_0x2A0", "UNUSED_0x2A0" }, + [673] = { "FLAG_UNUSED_0x2A1", "UNUSED_0x2A1" }, + [674] = { "FLAG_UNUSED_0x2A2", "UNUSED_0x2A2" }, + [675] = { "FLAG_UNUSED_0x2A3", "UNUSED_0x2A3" }, + [676] = { "FLAG_UNUSED_0x2A4", "UNUSED_0x2A4" }, + [677] = { "FLAG_UNUSED_0x2A5", "UNUSED_0x2A5" }, + [678] = { "FLAG_UNUSED_0x2A6", "UNUSED_0x2A6" }, + [679] = { "FLAG_UNUSED_0x2A7", "UNUSED_0x2A7" }, + [680] = { "FLAG_UNUSED_0x2A8", "UNUSED_0x2A8" }, + [681] = { "FLAG_UNUSED_0x2A9", "UNUSED_0x2A9" }, + [682] = { "FLAG_UNUSED_0x2AA", "UNUSED_0x2AA" }, + [683] = { "FLAG_UNUSED_0x2AB", "UNUSED_0x2AB" }, + [684] = { "FLAG_UNUSED_0x2AC", "UNUSED_0x2AC" }, + [685] = { "FLAG_UNUSED_0x2AD", "UNUSED_0x2AD" }, + [686] = { "FLAG_UNUSED_0x2AE", "UNUSED_0x2AE" }, + [687] = { "FLAG_UNUSED_0x2AF", "UNUSED_0x2AF" }, + [688] = { "FLAG_UNUSED_0x2B0", "UNUSED_0x2B0" }, + [689] = { "FLAG_UNUSED_0x2B1", "UNUSED_0x2B1" }, + [690] = { "FLAG_UNUSED_0x2B2", "UNUSED_0x2B2" }, + [691] = { "FLAG_UNUSED_0x2B3", "UNUSED_0x2B3" }, + [692] = { "FLAG_UNUSED_0x2B4", "UNUSED_0x2B4" }, + [693] = { "FLAG_UNUSED_0x2B5", "UNUSED_0x2B5" }, + [694] = { "FLAG_UNUSED_0x2B6", "UNUSED_0x2B6" }, + [695] = { "FLAG_UNUSED_0x2B7", "UNUSED_0x2B7" }, + [696] = { "FLAG_UNUSED_0x2B8", "UNUSED_0x2B8" }, + [697] = { "FLAG_UNUSED_0x2B9", "UNUSED_0x2B9" }, + [698] = { "FLAG_UNUSED_0x2BA", "UNUSED_0x2BA" }, + [699] = { "FLAG_UNUSED_0x2BB", "UNUSED_0x2BB" }, + [700] = { "FLAG_HIDE_ROUTE_101_BIRCH_STARTERS_BAG", "HIDE_ROUTE_101_BIRCH_STARTERS_BAG" }, + [701] = { "FLAG_HIDE_APPRENTICE", "HIDE_APPRENTICE" }, + [702] = { "FLAG_HIDE_POKEMON_CENTER_2F_MYSTERY_GIFT_MAN", "HIDE_POKEMON_CENTER_2F_MYSTERY_GIFT_MAN" }, + [703] = { "FLAG_HIDE_UNION_ROOM_PLAYER_1", "HIDE_UNION_ROOM_PLAYER_1" }, + [704] = { "FLAG_HIDE_UNION_ROOM_PLAYER_2", "HIDE_UNION_ROOM_PLAYER_2" }, + [705] = { "FLAG_HIDE_UNION_ROOM_PLAYER_3", "HIDE_UNION_ROOM_PLAYER_3" }, + [706] = { "FLAG_HIDE_UNION_ROOM_PLAYER_4", "HIDE_UNION_ROOM_PLAYER_4" }, + [707] = { "FLAG_HIDE_UNION_ROOM_PLAYER_5", "HIDE_UNION_ROOM_PLAYER_5" }, + [708] = { "FLAG_HIDE_UNION_ROOM_PLAYER_6", "HIDE_UNION_ROOM_PLAYER_6" }, + [709] = { "FLAG_HIDE_UNION_ROOM_PLAYER_7", "HIDE_UNION_ROOM_PLAYER_7" }, + [710] = { "FLAG_HIDE_UNION_ROOM_PLAYER_8", "HIDE_UNION_ROOM_PLAYER_8" }, + [711] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_1", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_1" }, + [712] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_2", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_2" }, + [713] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_3", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_3" }, + [714] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_4", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_4" }, + [715] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_5", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_5" }, + [716] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_6", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_6" }, + [717] = { "FLAG_HIDE_SAFARI_ZONE_SOUTH_CONSTRUCTION_WORKERS", "HIDE_SAFARI_ZONE_SOUTH_CONSTRUCTION_WORKERS" }, + [718] = { "FLAG_HIDE_MEW", "HIDE_MEW" }, + [719] = { "FLAG_HIDE_ROUTE_104_RIVAL", "HIDE_ROUTE_104_RIVAL" }, + [720] = { "FLAG_HIDE_ROUTE_101_BIRCH_ZIGZAGOON_BATTLE", "HIDE_ROUTE_101_BIRCH_ZIGZAGOON_BATTLE" }, + [721] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_BIRCH", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_BIRCH" }, + [722] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_BEDROOM", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_BEDROOM" }, + [723] = { "FLAG_HIDE_ROUTE_103_RIVAL", "HIDE_ROUTE_103_RIVAL" }, + [724] = { "FLAG_HIDE_PETALBURG_WOODS_DEVON_EMPLOYEE", "HIDE_PETALBURG_WOODS_DEVON_EMPLOYEE" }, + [725] = { "FLAG_HIDE_PETALBURG_WOODS_AQUA_GRUNT", "HIDE_PETALBURG_WOODS_AQUA_GRUNT" }, + [726] = { "FLAG_HIDE_PETALBURG_CITY_WALLY", "HIDE_PETALBURG_CITY_WALLY" }, + [727] = { "FLAG_HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_INVISIBLE_NINJA_BOY", "HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_INVISIBLE_NINJA_BOY" }, + [728] = { "FLAG_HIDE_PETALBURG_CITY_WALLYS_MOM", "HIDE_PETALBURG_CITY_WALLYS_MOM" }, + [729] = { "FLAG_UNUSED_0x2D9", "UNUSED_0x2D9" }, + [730] = { "FLAG_HIDE_LILYCOVE_FAN_CLUB_INTERVIEWER", "HIDE_LILYCOVE_FAN_CLUB_INTERVIEWER" }, + [731] = { "FLAG_HIDE_RUSTBORO_CITY_AQUA_GRUNT", "HIDE_RUSTBORO_CITY_AQUA_GRUNT" }, + [732] = { "FLAG_HIDE_RUSTBORO_CITY_DEVON_EMPLOYEE_1", "HIDE_RUSTBORO_CITY_DEVON_EMPLOYEE_1" }, + [733] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE_ASLEEP", "HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE_ASLEEP" }, + [734] = { "FLAG_HIDE_PLAYERS_HOUSE_DAD", "HIDE_PLAYERS_HOUSE_DAD" }, + [735] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_SIBLING", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_SIBLING" }, + [736] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_SIBLING", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_SIBLING" }, + [737] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_MAGMA_NOTE", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_MAGMA_NOTE" }, + [738] = { "FLAG_HIDE_ROUTE_104_MR_BRINEY", "HIDE_ROUTE_104_MR_BRINEY" }, + [739] = { "FLAG_HIDE_BRINEYS_HOUSE_MR_BRINEY", "HIDE_BRINEYS_HOUSE_MR_BRINEY" }, + [740] = { "FLAG_HIDE_MR_BRINEY_DEWFORD_TOWN", "HIDE_MR_BRINEY_DEWFORD_TOWN" }, + [741] = { "FLAG_HIDE_ROUTE_109_MR_BRINEY", "HIDE_ROUTE_109_MR_BRINEY" }, + [742] = { "FLAG_HIDE_ROUTE_104_MR_BRINEY_BOAT", "HIDE_ROUTE_104_MR_BRINEY_BOAT" }, + [743] = { "FLAG_HIDE_MR_BRINEY_BOAT_DEWFORD_TOWN", "HIDE_MR_BRINEY_BOAT_DEWFORD_TOWN" }, + [744] = { "FLAG_HIDE_ROUTE_109_MR_BRINEY_BOAT", "HIDE_ROUTE_109_MR_BRINEY_BOAT" }, + [745] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_BRENDAN", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_BRENDAN" }, + [746] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MAY", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MAY" }, + [747] = { "FLAG_HIDE_SAFARI_ZONE_SOUTH_EAST_EXPANSION", "HIDE_SAFARI_ZONE_SOUTH_EAST_EXPANSION" }, + [748] = { "FLAG_HIDE_LILYCOVE_HARBOR_EVENT_TICKET_TAKER", "HIDE_LILYCOVE_HARBOR_EVENT_TICKET_TAKER" }, + [749] = { "FLAG_HIDE_SLATEPORT_CITY_SCOTT", "HIDE_SLATEPORT_CITY_SCOTT" }, + [750] = { "FLAG_HIDE_ROUTE_101_ZIGZAGOON", "HIDE_ROUTE_101_ZIGZAGOON" }, + [751] = { "FLAG_HIDE_VICTORY_ROAD_EXIT_WALLY", "HIDE_VICTORY_ROAD_EXIT_WALLY" }, + [752] = { "FLAG_HIDE_LITTLEROOT_TOWN_MOM_OUTSIDE", "HIDE_LITTLEROOT_TOWN_MOM_OUTSIDE" }, + [753] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_STEVEN", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_STEVEN" }, + [754] = { "FLAG_HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_1", "HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_1" }, + [755] = { "FLAG_HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_2", "HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_2" }, + [756] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_TEAM_MAGMA", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_TEAM_MAGMA" }, + [757] = { "FLAG_HIDE_LITTLEROOT_TOWN_PLAYERS_BEDROOM_MOM", "HIDE_LITTLEROOT_TOWN_PLAYERS_BEDROOM_MOM" }, + [758] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_MOM", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_MOM" }, + [759] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MOM", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MOM" }, + [760] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_BEDROOM", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_BEDROOM" }, + [761] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_TRUCK", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_TRUCK" }, + [762] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_TRUCK", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_TRUCK" }, + [763] = { "FLAG_HIDE_DEOXYS", "HIDE_DEOXYS" }, + [764] = { "FLAG_HIDE_BIRTH_ISLAND_DEOXYS_TRIANGLE", "HIDE_BIRTH_ISLAND_DEOXYS_TRIANGLE" }, + [765] = { "FLAG_HIDE_MAUVILLE_CITY_SCOTT", "HIDE_MAUVILLE_CITY_SCOTT" }, + [766] = { "FLAG_HIDE_VERDANTURF_TOWN_SCOTT", "HIDE_VERDANTURF_TOWN_SCOTT" }, + [767] = { "FLAG_HIDE_FALLARBOR_TOWN_BATTLE_TENT_SCOTT", "HIDE_FALLARBOR_TOWN_BATTLE_TENT_SCOTT" }, + [768] = { "FLAG_HIDE_ROUTE_111_VICTOR_WINSTRATE", "HIDE_ROUTE_111_VICTOR_WINSTRATE" }, + [769] = { "FLAG_HIDE_ROUTE_111_VICTORIA_WINSTRATE", "HIDE_ROUTE_111_VICTORIA_WINSTRATE" }, + [770] = { "FLAG_HIDE_ROUTE_111_VIVI_WINSTRATE", "HIDE_ROUTE_111_VIVI_WINSTRATE" }, + [771] = { "FLAG_HIDE_ROUTE_111_VICKY_WINSTRATE", "HIDE_ROUTE_111_VICKY_WINSTRATE" }, + [772] = { "FLAG_HIDE_PETALBURG_GYM_NORMAN", "HIDE_PETALBURG_GYM_NORMAN" }, + [773] = { "FLAG_HIDE_SKY_PILLAR_TOP_RAYQUAZA", "HIDE_SKY_PILLAR_TOP_RAYQUAZA" }, + [774] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_1", "HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_1" }, + [775] = { "FLAG_HIDE_LILYCOVE_MUSEUM_CURATOR", "HIDE_LILYCOVE_MUSEUM_CURATOR" }, + [776] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_1", "HIDE_LILYCOVE_MUSEUM_PATRON_1" }, + [777] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_2", "HIDE_LILYCOVE_MUSEUM_PATRON_2" }, + [778] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_3", "HIDE_LILYCOVE_MUSEUM_PATRON_3" }, + [779] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_4", "HIDE_LILYCOVE_MUSEUM_PATRON_4" }, + [780] = { "FLAG_HIDE_LILYCOVE_MUSEUM_TOURISTS", "HIDE_LILYCOVE_MUSEUM_TOURISTS" }, + [781] = { "FLAG_HIDE_PETALBURG_GYM_GREETER", "HIDE_PETALBURG_GYM_GREETER" }, + [782] = { "FLAG_HIDE_MARINE_CAVE_KYOGRE", "HIDE_MARINE_CAVE_KYOGRE" }, + [783] = { "FLAG_HIDE_TERRA_CAVE_GROUDON", "HIDE_TERRA_CAVE_GROUDON" }, + [784] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_MOM", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_MOM" }, + [785] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_MOM", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_MOM" }, + [786] = { "FLAG_HIDE_ROUTE_119_SCOTT", "HIDE_ROUTE_119_SCOTT" }, + [787] = { "FLAG_HIDE_LILYCOVE_MOTEL_SCOTT", "HIDE_LILYCOVE_MOTEL_SCOTT" }, + [788] = { "FLAG_HIDE_MOSSDEEP_CITY_SCOTT", "HIDE_MOSSDEEP_CITY_SCOTT" }, + [789] = { "FLAG_HIDE_FANCLUB_OLD_LADY", "HIDE_FANCLUB_OLD_LADY" }, + [790] = { "FLAG_HIDE_FANCLUB_BOY", "HIDE_FANCLUB_BOY" }, + [791] = { "FLAG_HIDE_FANCLUB_LITTLE_BOY", "HIDE_FANCLUB_LITTLE_BOY" }, + [792] = { "FLAG_HIDE_FANCLUB_LADY", "HIDE_FANCLUB_LADY" }, + [793] = { "FLAG_HIDE_EVER_GRANDE_POKEMON_CENTER_1F_SCOTT", "HIDE_EVER_GRANDE_POKEMON_CENTER_1F_SCOTT" }, + [794] = { "FLAG_HIDE_LITTLEROOT_TOWN_RIVAL", "HIDE_LITTLEROOT_TOWN_RIVAL" }, + [795] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCH", "HIDE_LITTLEROOT_TOWN_BIRCH" }, + [796] = { "FLAG_HIDE_ROUTE_111_GABBY_AND_TY_1", "HIDE_ROUTE_111_GABBY_AND_TY_1" }, + [797] = { "FLAG_HIDE_ROUTE_118_GABBY_AND_TY_1", "HIDE_ROUTE_118_GABBY_AND_TY_1" }, + [798] = { "FLAG_HIDE_ROUTE_120_GABBY_AND_TY_1", "HIDE_ROUTE_120_GABBY_AND_TY_1" }, + [799] = { "FLAG_HIDE_ROUTE_111_GABBY_AND_TY_2", "HIDE_ROUTE_111_GABBY_AND_TY_2" }, + [800] = { "FLAG_HIDE_LUGIA", "HIDE_LUGIA" }, + [801] = { "FLAG_HIDE_HO_OH", "HIDE_HO_OH" }, + [802] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_REPORTER", "HIDE_LILYCOVE_CONTEST_HALL_REPORTER" }, + [803] = { "FLAG_HIDE_SLATEPORT_CITY_CONTEST_REPORTER", "HIDE_SLATEPORT_CITY_CONTEST_REPORTER" }, + [804] = { "FLAG_HIDE_MAUVILLE_CITY_WALLY", "HIDE_MAUVILLE_CITY_WALLY" }, + [805] = { "FLAG_HIDE_MAUVILLE_CITY_WALLYS_UNCLE", "HIDE_MAUVILLE_CITY_WALLYS_UNCLE" }, + [806] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLY", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLY" }, + [807] = { "FLAG_HIDE_RUSTURF_TUNNEL_WANDAS_BOYFRIEND", "HIDE_RUSTURF_TUNNEL_WANDAS_BOYFRIEND" }, + [808] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDAS_BOYFRIEND", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDAS_BOYFRIEND" }, + [809] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLYS_UNCLE", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLYS_UNCLE" }, + [810] = { "FLAG_HIDE_SS_TIDAL_CORRIDOR_SCOTT", "HIDE_SS_TIDAL_CORRIDOR_SCOTT" }, + [811] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CYNDAQUIL", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CYNDAQUIL" }, + [812] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_TOTODILE", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_TOTODILE" }, + [813] = { "FLAG_HIDE_ROUTE_116_DROPPED_GLASSES_MAN", "HIDE_ROUTE_116_DROPPED_GLASSES_MAN" }, + [814] = { "FLAG_HIDE_RUSTBORO_CITY_RIVAL", "HIDE_RUSTBORO_CITY_RIVAL" }, + [815] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_SWABLU_DOLL", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_SWABLU_DOLL" }, + [816] = { "FLAG_HIDE_SOOTOPOLIS_CITY_WALLACE", "HIDE_SOOTOPOLIS_CITY_WALLACE" }, + [817] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_POKE_BALL", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_POKE_BALL" }, + [818] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_POKE_BALL", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_POKE_BALL" }, + [819] = { "FLAG_HIDE_ROUTE_112_TEAM_MAGMA", "HIDE_ROUTE_112_TEAM_MAGMA" }, + [820] = { "FLAG_HIDE_CAVE_OF_ORIGIN_B1F_WALLACE", "HIDE_CAVE_OF_ORIGIN_B1F_WALLACE" }, + [821] = { "FLAG_HIDE_AQUA_HIDEOUT_1F_GRUNT_1_BLOCKING_ENTRANCE", "HIDE_AQUA_HIDEOUT_1F_GRUNT_1_BLOCKING_ENTRANCE" }, + [822] = { "FLAG_HIDE_AQUA_HIDEOUT_1F_GRUNT_2_BLOCKING_ENTRANCE", "HIDE_AQUA_HIDEOUT_1F_GRUNT_2_BLOCKING_ENTRANCE" }, + [823] = { "FLAG_HIDE_MOSSDEEP_CITY_TEAM_MAGMA", "HIDE_MOSSDEEP_CITY_TEAM_MAGMA" }, + [824] = { "FLAG_HIDE_PETALBURG_GYM_WALLYS_DAD", "HIDE_PETALBURG_GYM_WALLYS_DAD" }, + [825] = { "FLAG_HIDE_LEGEND_MON_CAVE_OF_ORIGIN", "HIDE_LEGEND_MON_CAVE_OF_ORIGIN" }, + [826] = { "FLAG_HIDE_SOOTOPOLIS_CITY_ARCHIE", "HIDE_SOOTOPOLIS_CITY_ARCHIE" }, + [827] = { "FLAG_HIDE_SOOTOPOLIS_CITY_MAXIE", "HIDE_SOOTOPOLIS_CITY_MAXIE" }, + [828] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_ARCHIE", "HIDE_SEAFLOOR_CAVERN_ROOM_9_ARCHIE" }, + [829] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_MAXIE", "HIDE_SEAFLOOR_CAVERN_ROOM_9_MAXIE" }, + [830] = { "FLAG_HIDE_PETALBURG_CITY_WALLYS_DAD", "HIDE_PETALBURG_CITY_WALLYS_DAD" }, + [831] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_MAGMA_GRUNTS", "HIDE_SEAFLOOR_CAVERN_ROOM_9_MAGMA_GRUNTS" }, + [832] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER", "HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER" }, + [833] = { "FLAG_HIDE_GRANITE_CAVE_STEVEN", "HIDE_GRANITE_CAVE_STEVEN" }, + [834] = { "FLAG_HIDE_ROUTE_128_STEVEN", "HIDE_ROUTE_128_STEVEN" }, + [835] = { "FLAG_HIDE_SLATEPORT_CITY_GABBY_AND_TY", "HIDE_SLATEPORT_CITY_GABBY_AND_TY" }, + [836] = { "FLAG_HIDE_BATTLE_FRONTIER_RECEPTION_GATE_SCOTT", "HIDE_BATTLE_FRONTIER_RECEPTION_GATE_SCOTT" }, + [837] = { "FLAG_HIDE_ROUTE_110_BIRCH", "HIDE_ROUTE_110_BIRCH" }, + [838] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CHIKORITA", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CHIKORITA" }, + [839] = { "FLAG_HIDE_SOOTOPOLIS_CITY_MAN_1", "HIDE_SOOTOPOLIS_CITY_MAN_1" }, + [840] = { "FLAG_HIDE_SLATEPORT_CITY_CAPTAIN_STERN", "HIDE_SLATEPORT_CITY_CAPTAIN_STERN" }, + [841] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_CAPTAIN_STERN", "HIDE_SLATEPORT_CITY_HARBOR_CAPTAIN_STERN" }, + [842] = { "FLAG_HIDE_BATTLE_FRONTIER_SUDOWOODO", "HIDE_BATTLE_FRONTIER_SUDOWOODO" }, + [843] = { "FLAG_HIDE_ROUTE_111_ROCK_SMASH_TIP_GUY", "HIDE_ROUTE_111_ROCK_SMASH_TIP_GUY" }, + [844] = { "FLAG_HIDE_RUSTBORO_CITY_SCIENTIST", "HIDE_RUSTBORO_CITY_SCIENTIST" }, + [845] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_AQUA_GRUNT", "HIDE_SLATEPORT_CITY_HARBOR_AQUA_GRUNT" }, + [846] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_ARCHIE", "HIDE_SLATEPORT_CITY_HARBOR_ARCHIE" }, + [847] = { "FLAG_HIDE_JAGGED_PASS_MAGMA_GUARD", "HIDE_JAGGED_PASS_MAGMA_GUARD" }, + [848] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_SUBMARINE_SHADOW", "HIDE_SLATEPORT_CITY_HARBOR_SUBMARINE_SHADOW" }, + [849] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_PICHU_DOLL", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_PICHU_DOLL" }, + [850] = { "FLAG_HIDE_MAGMA_HIDEOUT_4F_GROUDON_ASLEEP", "HIDE_MAGMA_HIDEOUT_4F_GROUDON_ASLEEP" }, + [851] = { "FLAG_HIDE_ROUTE_119_RIVAL", "HIDE_ROUTE_119_RIVAL" }, + [852] = { "FLAG_HIDE_LILYCOVE_CITY_AQUA_GRUNTS", "HIDE_LILYCOVE_CITY_AQUA_GRUNTS" }, + [853] = { "FLAG_HIDE_MAGMA_HIDEOUT_4F_GROUDON", "HIDE_MAGMA_HIDEOUT_4F_GROUDON" }, + [854] = { "FLAG_HIDE_SOOTOPOLIS_CITY_RESIDENTS", "HIDE_SOOTOPOLIS_CITY_RESIDENTS" }, + [855] = { "FLAG_HIDE_SKY_PILLAR_WALLACE", "HIDE_SKY_PILLAR_WALLACE" }, + [856] = { "FLAG_HIDE_MT_PYRE_SUMMIT_MAXIE", "HIDE_MT_PYRE_SUMMIT_MAXIE" }, + [857] = { "FLAG_HIDE_MAGMA_HIDEOUT_GRUNTS", "HIDE_MAGMA_HIDEOUT_GRUNTS" }, + [858] = { "FLAG_HIDE_VICTORY_ROAD_ENTRANCE_WALLY", "HIDE_VICTORY_ROAD_ENTRANCE_WALLY" }, + [859] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE", "HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE" }, + [860] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_SS_TIDAL", "HIDE_SLATEPORT_CITY_HARBOR_SS_TIDAL" }, + [861] = { "FLAG_HIDE_LILYCOVE_HARBOR_SSTIDAL", "HIDE_LILYCOVE_HARBOR_SSTIDAL" }, + [862] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_TEAM_MAGMA", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_TEAM_MAGMA" }, + [863] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_STEVEN", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_STEVEN" }, + [864] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_1", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_1" }, + [865] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_2", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_2" }, + [866] = { "FLAG_HIDE_PETALBURG_GYM_WALLY", "HIDE_PETALBURG_GYM_WALLY" }, + [867] = { "FLAG_UNKNOWN_0x363", "UNKNOWN_0x363" }, + [868] = { "FLAG_HIDE_LITTLEROOT_TOWN_FAT_MAN", "HIDE_LITTLEROOT_TOWN_FAT_MAN" }, + [869] = { "FLAG_HIDE_SLATEPORT_CITY_STERNS_SHIPYARD_MR_BRINEY", "HIDE_SLATEPORT_CITY_STERNS_SHIPYARD_MR_BRINEY" }, + [870] = { "FLAG_HIDE_LANETTES_HOUSE_LANETTE", "HIDE_LANETTES_HOUSE_LANETTE" }, + [871] = { "FLAG_HIDE_FALLARBOR_POKEMON_CENTER_LANETTE", "HIDE_FALLARBOR_POKEMON_CENTER_LANETTE" }, + [872] = { "FLAG_HIDE_TRICK_HOUSE_ENTRANCE_MAN", "HIDE_TRICK_HOUSE_ENTRANCE_MAN" }, + [873] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER_REPLACEMENT", "HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER_REPLACEMENT" }, + [874] = { "FLAG_HIDE_DESERT_UNDERPASS_FOSSIL", "HIDE_DESERT_UNDERPASS_FOSSIL" }, + [875] = { "FLAG_HIDE_ROUTE_111_PLAYER_DESCENT", "HIDE_ROUTE_111_PLAYER_DESCENT" }, + [876] = { "FLAG_HIDE_ROUTE_111_DESERT_FOSSIL", "HIDE_ROUTE_111_DESERT_FOSSIL" }, + [877] = { "FLAG_HIDE_MT_CHIMNEY_TRAINERS", "HIDE_MT_CHIMNEY_TRAINERS" }, + [878] = { "FLAG_HIDE_RUSTURF_TUNNEL_AQUA_GRUNT", "HIDE_RUSTURF_TUNNEL_AQUA_GRUNT" }, + [879] = { "FLAG_HIDE_RUSTURF_TUNNEL_BRINEY", "HIDE_RUSTURF_TUNNEL_BRINEY" }, + [880] = { "FLAG_HIDE_RUSTURF_TUNNEL_PEEKO", "HIDE_RUSTURF_TUNNEL_PEEKO" }, + [881] = { "FLAG_HIDE_BRINEYS_HOUSE_PEEKO", "HIDE_BRINEYS_HOUSE_PEEKO" }, + [882] = { "FLAG_HIDE_SLATEPORT_CITY_TEAM_AQUA", "HIDE_SLATEPORT_CITY_TEAM_AQUA" }, + [883] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_AQUA_GRUNTS", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_AQUA_GRUNTS" }, + [884] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_1", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_1" }, + [885] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_2", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_2" }, + [886] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_ARCHIE", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_ARCHIE" }, + [887] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_CAPTAIN_STERN", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_CAPTAIN_STERN" }, + [888] = { "FLAG_HIDE_BATTLE_TOWER_OPPONENT", "HIDE_BATTLE_TOWER_OPPONENT" }, + [889] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_RIVAL", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_RIVAL" }, + [890] = { "FLAG_HIDE_ROUTE_119_TEAM_AQUA", "HIDE_ROUTE_119_TEAM_AQUA" }, + [891] = { "FLAG_HIDE_ROUTE_116_MR_BRINEY", "HIDE_ROUTE_116_MR_BRINEY" }, + [892] = { "FLAG_HIDE_WEATHER_INSTITUTE_1F_WORKERS", "HIDE_WEATHER_INSTITUTE_1F_WORKERS" }, + [893] = { "FLAG_HIDE_WEATHER_INSTITUTE_2F_WORKERS", "HIDE_WEATHER_INSTITUTE_2F_WORKERS" }, + [894] = { "FLAG_HIDE_ROUTE_116_WANDAS_BOYFRIEND", "HIDE_ROUTE_116_WANDAS_BOYFRIEND" }, + [895] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_2", "HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_2" }, + [896] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_UNKNOWN_0x380", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_UNKNOWN_0x380" }, + [897] = { "FLAG_HIDE_ROUTE_101_BIRCH", "HIDE_ROUTE_101_BIRCH" }, + [898] = { "FLAG_HIDE_ROUTE_103_BIRCH", "HIDE_ROUTE_103_BIRCH" }, + [899] = { "FLAG_HIDE_TRICK_HOUSE_END_MAN", "HIDE_TRICK_HOUSE_END_MAN" }, + [900] = { "FLAG_HIDE_ROUTE_110_TEAM_AQUA", "HIDE_ROUTE_110_TEAM_AQUA" }, + [901] = { "FLAG_HIDE_ROUTE_118_GABBY_AND_TY_2", "HIDE_ROUTE_118_GABBY_AND_TY_2" }, + [902] = { "FLAG_HIDE_ROUTE_120_GABBY_AND_TY_2", "HIDE_ROUTE_120_GABBY_AND_TY_2" }, + [903] = { "FLAG_HIDE_ROUTE_111_GABBY_AND_TY_3", "HIDE_ROUTE_111_GABBY_AND_TY_3" }, + [904] = { "FLAG_HIDE_ROUTE_118_GABBY_AND_TY_3", "HIDE_ROUTE_118_GABBY_AND_TY_3" }, + [905] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_PATRONS", "HIDE_SLATEPORT_CITY_HARBOR_PATRONS" }, + [906] = { "FLAG_HIDE_ROUTE_104_WHITE_HERB_FLORIST", "HIDE_ROUTE_104_WHITE_HERB_FLORIST" }, + [907] = { "FLAG_HIDE_FALLARBOR_AZURILL", "HIDE_FALLARBOR_AZURILL" }, + [908] = { "FLAG_HIDE_LILYCOVE_HARBOR_FERRY_ATTENDANT", "HIDE_LILYCOVE_HARBOR_FERRY_ATTENDANT" }, + [909] = { "FLAG_HIDE_LILYCOVE_HARBOR_FERRY_SAILOR", "HIDE_LILYCOVE_HARBOR_FERRY_SAILOR" }, + [910] = { "FLAG_HIDE_SOUTHERN_ISLAND_EON_STONE", "HIDE_SOUTHERN_ISLAND_EON_STONE" }, + [911] = { "FLAG_HIDE_SOUTHERN_ISLAND_UNCHOSEN_EON_DUO_MON", "HIDE_SOUTHERN_ISLAND_UNCHOSEN_EON_DUO_MON" }, + [912] = { "FLAG_HIDE_MAUVILLE_CITY_WATTSON", "HIDE_MAUVILLE_CITY_WATTSON" }, + [913] = { "FLAG_HIDE_MAUVILLE_GYM_WATTSON", "HIDE_MAUVILLE_GYM_WATTSON" }, + [914] = { "FLAG_HIDE_ROUTE_121_TEAM_AQUA_GRUNTS", "HIDE_ROUTE_121_TEAM_AQUA_GRUNTS" }, + [915] = { "FLAG_UNKNOWN_0x393", "UNKNOWN_0x393" }, + [916] = { "FLAG_HIDE_MT_PYRE_SUMMIT_ARCHIE", "HIDE_MT_PYRE_SUMMIT_ARCHIE" }, + [917] = { "FLAG_HIDE_MT_PYRE_SUMMIT_TEAM_AQUA", "HIDE_MT_PYRE_SUMMIT_TEAM_AQUA" }, + [918] = { "FLAG_HIDE_BATTLE_TOWER_REPORTER", "HIDE_BATTLE_TOWER_REPORTER" }, + [919] = { "FLAG_HIDE_ROUTE_110_RIVAL", "HIDE_ROUTE_110_RIVAL" }, + [920] = { "FLAG_HIDE_CHAMPIONS_ROOM_RIVAL", "HIDE_CHAMPIONS_ROOM_RIVAL" }, + [921] = { "FLAG_HIDE_CHAMPIONS_ROOM_BIRCH", "HIDE_CHAMPIONS_ROOM_BIRCH" }, + [922] = { "FLAG_HIDE_ROUTE_110_RIVAL_ON_BIKE", "HIDE_ROUTE_110_RIVAL_ON_BIKE" }, + [923] = { "FLAG_HIDE_ROUTE_119_RIVAL_ON_BIKE", "HIDE_ROUTE_119_RIVAL_ON_BIKE" }, + [924] = { "FLAG_HIDE_AQUA_HIDEOUT_GRUNTS", "HIDE_AQUA_HIDEOUT_GRUNTS" }, + [925] = { "FLAG_HIDE_LILYCOVE_MOTEL_GAME_DESIGNERS", "HIDE_LILYCOVE_MOTEL_GAME_DESIGNERS" }, + [926] = { "FLAG_HIDE_MT_CHIMNEY_TEAM_AQUA", "HIDE_MT_CHIMNEY_TEAM_AQUA" }, + [927] = { "FLAG_HIDE_MT_CHIMNEY_TEAM_MAGMA", "HIDE_MT_CHIMNEY_TEAM_MAGMA" }, + [928] = { "FLAG_HIDE_FALLARBOR_HOUSE_PROF_COZMO", "HIDE_FALLARBOR_HOUSE_PROF_COZMO" }, + [929] = { "FLAG_HIDE_LAVARIDGE_TOWN_RIVAL", "HIDE_LAVARIDGE_TOWN_RIVAL" }, + [930] = { "FLAG_HIDE_LAVARIDGE_TOWN_RIVAL_ON_BIKE", "HIDE_LAVARIDGE_TOWN_RIVAL_ON_BIKE" }, + [931] = { "FLAG_HIDE_RUSTURF_TUNNEL_ROCK_1", "HIDE_RUSTURF_TUNNEL_ROCK_1" }, + [932] = { "FLAG_HIDE_RUSTURF_TUNNEL_ROCK_2", "HIDE_RUSTURF_TUNNEL_ROCK_2" }, + [933] = { "FLAG_HIDE_FORTREE_CITY_HOUSE_4_WINGULL", "HIDE_FORTREE_CITY_HOUSE_4_WINGULL" }, + [934] = { "FLAG_HIDE_MOSSDEEP_CITY_HOUSE_2_WINGULL", "HIDE_MOSSDEEP_CITY_HOUSE_2_WINGULL" }, + [935] = { "FLAG_HIDE_REGIROCK", "HIDE_REGIROCK" }, + [936] = { "FLAG_HIDE_REGICE", "HIDE_REGICE" }, + [937] = { "FLAG_HIDE_REGISTEEL", "HIDE_REGISTEEL" }, + [938] = { "FLAG_HIDE_METEOR_FALLS_TEAM_AQUA", "HIDE_METEOR_FALLS_TEAM_AQUA" }, + [939] = { "FLAG_HIDE_METEOR_FALLS_TEAM_MAGMA", "HIDE_METEOR_FALLS_TEAM_MAGMA" }, + [940] = { "FLAG_HIDE_DEWFORD_HALL_SLUDGE_BOMB_MAN", "HIDE_DEWFORD_HALL_SLUDGE_BOMB_MAN" }, + [941] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ENTRANCE_AQUA_GRUNT", "HIDE_SEAFLOOR_CAVERN_ENTRANCE_AQUA_GRUNT" }, + [942] = { "FLAG_HIDE_METEOR_FALLS_1F_1R_COZMO", "HIDE_METEOR_FALLS_1F_1R_COZMO" }, + [943] = { "FLAG_HIDE_AQUA_HIDEOUT_B2F_SUBMARINE_SHADOW", "HIDE_AQUA_HIDEOUT_B2F_SUBMARINE_SHADOW" }, + [944] = { "FLAG_HIDE_ROUTE_128_ARCHIE", "HIDE_ROUTE_128_ARCHIE" }, + [945] = { "FLAG_HIDE_ROUTE_128_MAXIE", "HIDE_ROUTE_128_MAXIE" }, + [946] = { "FLAG_HIDE_SEAFLOOR_CAVERN_AQUA_GRUNTS", "HIDE_SEAFLOOR_CAVERN_AQUA_GRUNTS" }, + [947] = { "FLAG_HIDE_ROUTE_116_DEVON_EMPLOYEE", "HIDE_ROUTE_116_DEVON_EMPLOYEE" }, + [948] = { "FLAG_HIDE_SLATEPORT_CITY_TM_SALESMAN", "HIDE_SLATEPORT_CITY_TM_SALESMAN" }, + [949] = { "FLAG_HIDE_RUSTBORO_CITY_DEVON_CORP_3F_EMPLOYEE", "HIDE_RUSTBORO_CITY_DEVON_CORP_3F_EMPLOYEE" }, + [950] = { "FLAG_HIDE_SS_TIDAL_CORRIDOR_MR_BRINEY", "HIDE_SS_TIDAL_CORRIDOR_MR_BRINEY" }, + [951] = { "FLAG_HIDE_SS_TIDAL_ROOMS_SNATCH_GIVER", "HIDE_SS_TIDAL_ROOMS_SNATCH_GIVER" }, + [952] = { "FLAG_RECEIVED_SHOAL_SALT_1", "RECEIVED_SHOAL_SALT_1" }, + [953] = { "FLAG_RECEIVED_SHOAL_SALT_2", "RECEIVED_SHOAL_SALT_2" }, + [954] = { "FLAG_RECEIVED_SHOAL_SALT_3", "RECEIVED_SHOAL_SALT_3" }, + [955] = { "FLAG_RECEIVED_SHOAL_SALT_4", "RECEIVED_SHOAL_SALT_4" }, + [956] = { "FLAG_RECEIVED_SHOAL_SHELL_1", "RECEIVED_SHOAL_SHELL_1" }, + [957] = { "FLAG_RECEIVED_SHOAL_SHELL_2", "RECEIVED_SHOAL_SHELL_2" }, + [958] = { "FLAG_RECEIVED_SHOAL_SHELL_3", "RECEIVED_SHOAL_SHELL_3" }, + [959] = { "FLAG_RECEIVED_SHOAL_SHELL_4", "RECEIVED_SHOAL_SHELL_4" }, + [960] = { "FLAG_HIDE_ROUTE_111_SECRET_POWER_MAN", "HIDE_ROUTE_111_SECRET_POWER_MAN" }, + [961] = { "FLAG_HIDE_SLATEPORT_MUSEUM_POPULATION", "HIDE_SLATEPORT_MUSEUM_POPULATION" }, + [962] = { "FLAG_HIDE_LILYCOVE_DEPARTMENT_STORE_ROOFTOP_SALE_WOMAN", "HIDE_LILYCOVE_DEPARTMENT_STORE_ROOFTOP_SALE_WOMAN" }, + [963] = { "FLAG_HIDE_MIRAGE_TOWER_ROOT_FOSSIL", "HIDE_MIRAGE_TOWER_ROOT_FOSSIL" }, + [964] = { "FLAG_HIDE_MIRAGE_TOWER_CLAW_FOSSIL", "HIDE_MIRAGE_TOWER_CLAW_FOSSIL" }, + [965] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_FAMILIAR_AQUA_GRUNT", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_FAMILIAR_AQUA_GRUNT" }, + [966] = { "FLAG_HIDE_ROUTE_118_STEVEN", "HIDE_ROUTE_118_STEVEN" }, + [967] = { "FLAG_HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_STEVEN", "HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_STEVEN" }, + [968] = { "FLAG_HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_BELDUM_POKEBALL", "HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_BELDUM_POKEBALL" }, + [969] = { "FLAG_HIDE_FORTREE_CITY_KECLEON", "HIDE_FORTREE_CITY_KECLEON" }, + [970] = { "FLAG_HIDE_ROUTE_120_KECLEON_BRIDGE", "HIDE_ROUTE_120_KECLEON_BRIDGE" }, + [971] = { "FLAG_HIDE_LILYCOVE_CITY_RIVAL", "HIDE_LILYCOVE_CITY_RIVAL" }, + [972] = { "FLAG_HIDE_ROUTE_120_STEVEN", "HIDE_ROUTE_120_STEVEN" }, + [973] = { "FLAG_HIDE_SOOTOPOLIS_CITY_STEVEN", "HIDE_SOOTOPOLIS_CITY_STEVEN" }, + [974] = { "FLAG_HIDE_NEW_MAUVILLE_VOLTORB_1", "HIDE_NEW_MAUVILLE_VOLTORB_1" }, + [975] = { "FLAG_HIDE_NEW_MAUVILLE_VOLTORB_2", "HIDE_NEW_MAUVILLE_VOLTORB_2" }, + [976] = { "FLAG_HIDE_NEW_MAUVILLE_VOLTORB_3", "HIDE_NEW_MAUVILLE_VOLTORB_3" }, + [977] = { "FLAG_HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_1", "HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_1" }, + [978] = { "FLAG_HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_2", "HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_2" }, + [979] = { "FLAG_HIDE_OLDALE_TOWN_RIVAL", "HIDE_OLDALE_TOWN_RIVAL" }, + [980] = { "FLAG_HIDE_UNDERWATER_SEA_FLOOR_CAVERN_STOLEN_SUBMARINE", "HIDE_UNDERWATER_SEA_FLOOR_CAVERN_STOLEN_SUBMARINE" }, + [981] = { "FLAG_HIDE_ROUTE_120_KECLEON_BRIDGE_SHADOW", "HIDE_ROUTE_120_KECLEON_BRIDGE_SHADOW" }, + [982] = { "FLAG_HIDE_ROUTE_120_KECLEON_1", "HIDE_ROUTE_120_KECLEON_1" }, + [983] = { "FLAG_HIDE_RUSTURF_TUNNEL_WANDA", "HIDE_RUSTURF_TUNNEL_WANDA" }, + [984] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDA", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDA" }, + [985] = { "FLAG_HIDE_ROUTE_120_KECLEON_2", "HIDE_ROUTE_120_KECLEON_2" }, + [986] = { "FLAG_HIDE_ROUTE_120_KECLEON_3", "HIDE_ROUTE_120_KECLEON_3" }, + [987] = { "FLAG_HIDE_ROUTE_120_KECLEON_4", "HIDE_ROUTE_120_KECLEON_4" }, + [988] = { "FLAG_HIDE_ROUTE_120_KECLEON_5", "HIDE_ROUTE_120_KECLEON_5" }, + [989] = { "FLAG_HIDE_ROUTE_119_KECLEON_1", "HIDE_ROUTE_119_KECLEON_1" }, + [990] = { "FLAG_HIDE_ROUTE_119_KECLEON_2", "HIDE_ROUTE_119_KECLEON_2" }, + [991] = { "FLAG_HIDE_ROUTE_101_BOY", "HIDE_ROUTE_101_BOY" }, + [992] = { "FLAG_HIDE_WEATHER_INSTITUTE_2F_AQUA_GRUNT_M", "HIDE_WEATHER_INSTITUTE_2F_AQUA_GRUNT_M" }, + [993] = { "FLAG_HIDE_LILYCOVE_POKEMON_CENTER_CONTEST_LADY_MON", "HIDE_LILYCOVE_POKEMON_CENTER_CONTEST_LADY_MON" }, + [994] = { "FLAG_HIDE_MT_CHIMNEY_LAVA_COOKIE_LADY", "HIDE_MT_CHIMNEY_LAVA_COOKIE_LADY" }, + [995] = { "FLAG_HIDE_PETALBURG_CITY_SCOTT", "HIDE_PETALBURG_CITY_SCOTT" }, + [996] = { "FLAG_HIDE_SOOTOPOLIS_CITY_RAYQUAZA", "HIDE_SOOTOPOLIS_CITY_RAYQUAZA" }, + [997] = { "FLAG_HIDE_SOOTOPOLIS_CITY_KYOGRE", "HIDE_SOOTOPOLIS_CITY_KYOGRE" }, + [998] = { "FLAG_HIDE_SOOTOPOLIS_CITY_GROUDON", "HIDE_SOOTOPOLIS_CITY_GROUDON" }, + [999] = { "FLAG_HIDE_RUSTBORO_CITY_POKEMON_SCHOOL_SCOTT", "HIDE_RUSTBORO_CITY_POKEMON_SCHOOL_SCOTT" }, + [1000] = { "FLAG_ITEM_ROUTE_102_POTION", "ITEM_ROUTE_102_POTION" }, + [1001] = { "FLAG_ITEM_ROUTE_116_X_SPECIAL", "ITEM_ROUTE_116_X_SPECIAL" }, + [1002] = { "FLAG_ITEM_ROUTE_104_PP_UP", "ITEM_ROUTE_104_PP_UP" }, + [1003] = { "FLAG_ITEM_ROUTE_105_IRON", "ITEM_ROUTE_105_IRON" }, + [1004] = { "FLAG_ITEM_ROUTE_106_PROTEIN", "ITEM_ROUTE_106_PROTEIN" }, + [1005] = { "FLAG_ITEM_ROUTE_109_PP_UP", "ITEM_ROUTE_109_PP_UP" }, + [1006] = { "FLAG_ITEM_ROUTE_110_RARE_CANDY", "ITEM_ROUTE_110_RARE_CANDY" }, + [1007] = { "FLAG_ITEM_ROUTE_110_DIRE_HIT", "ITEM_ROUTE_110_DIRE_HIT" }, + [1008] = { "FLAG_ITEM_ROUTE_111_TM_SANDSTORM", "ITEM_ROUTE_111_TM_SANDSTORM" }, + [1009] = { "FLAG_ITEM_ROUTE_111_STARDUST", "ITEM_ROUTE_111_STARDUST" }, + [1010] = { "FLAG_ITEM_ROUTE_111_HP_UP", "ITEM_ROUTE_111_HP_UP" }, + [1011] = { "FLAG_ITEM_ROUTE_112_NUGGET", "ITEM_ROUTE_112_NUGGET" }, + [1012] = { "FLAG_ITEM_ROUTE_113_MAX_ETHER", "ITEM_ROUTE_113_MAX_ETHER" }, + [1013] = { "FLAG_ITEM_ROUTE_113_SUPER_REPEL", "ITEM_ROUTE_113_SUPER_REPEL" }, + [1014] = { "FLAG_ITEM_ROUTE_114_RARE_CANDY", "ITEM_ROUTE_114_RARE_CANDY" }, + [1015] = { "FLAG_ITEM_ROUTE_114_PROTEIN", "ITEM_ROUTE_114_PROTEIN" }, + [1016] = { "FLAG_ITEM_ROUTE_115_SUPER_POTION", "ITEM_ROUTE_115_SUPER_POTION" }, + [1017] = { "FLAG_ITEM_ROUTE_115_TM_FOCUS_PUNCH", "ITEM_ROUTE_115_TM_FOCUS_PUNCH" }, + [1018] = { "FLAG_ITEM_ROUTE_115_IRON", "ITEM_ROUTE_115_IRON" }, + [1019] = { "FLAG_ITEM_ROUTE_116_ETHER", "ITEM_ROUTE_116_ETHER" }, + [1020] = { "FLAG_ITEM_ROUTE_116_REPEL", "ITEM_ROUTE_116_REPEL" }, + [1021] = { "FLAG_ITEM_ROUTE_116_HP_UP", "ITEM_ROUTE_116_HP_UP" }, + [1022] = { "FLAG_ITEM_ROUTE_117_GREAT_BALL", "ITEM_ROUTE_117_GREAT_BALL" }, + [1023] = { "FLAG_ITEM_ROUTE_117_REVIVE", "ITEM_ROUTE_117_REVIVE" }, + [1024] = { "FLAG_ITEM_ROUTE_119_SUPER_REPEL", "ITEM_ROUTE_119_SUPER_REPEL" }, + [1025] = { "FLAG_ITEM_ROUTE_119_ZINC", "ITEM_ROUTE_119_ZINC" }, + [1026] = { "FLAG_ITEM_ROUTE_119_ELIXIR_1", "ITEM_ROUTE_119_ELIXIR_1" }, + [1027] = { "FLAG_ITEM_ROUTE_119_LEAF_STONE", "ITEM_ROUTE_119_LEAF_STONE" }, + [1028] = { "FLAG_ITEM_ROUTE_119_RARE_CANDY", "ITEM_ROUTE_119_RARE_CANDY" }, + [1029] = { "FLAG_ITEM_ROUTE_119_HYPER_POTION_1", "ITEM_ROUTE_119_HYPER_POTION_1" }, + [1030] = { "FLAG_ITEM_ROUTE_120_NUGGET", "ITEM_ROUTE_120_NUGGET" }, + [1031] = { "FLAG_ITEM_ROUTE_120_FULL_HEAL", "ITEM_ROUTE_120_FULL_HEAL" }, + [1032] = { "FLAG_ITEM_ROUTE_123_CALCIUM", "ITEM_ROUTE_123_CALCIUM" }, + [1033] = { "FLAG_ITEM_ROUTE_123_RARE_CANDY", "ITEM_ROUTE_123_RARE_CANDY" }, + [1034] = { "FLAG_ITEM_ROUTE_127_ZINC", "ITEM_ROUTE_127_ZINC" }, + [1035] = { "FLAG_ITEM_ROUTE_127_CARBOS", "ITEM_ROUTE_127_CARBOS" }, + [1036] = { "FLAG_ITEM_ROUTE_132_RARE_CANDY", "ITEM_ROUTE_132_RARE_CANDY" }, + [1037] = { "FLAG_ITEM_ROUTE_133_BIG_PEARL", "ITEM_ROUTE_133_BIG_PEARL" }, + [1038] = { "FLAG_ITEM_ROUTE_133_STAR_PIECE", "ITEM_ROUTE_133_STAR_PIECE" }, + [1039] = { "FLAG_ITEM_PETALBURG_CITY_MAX_REVIVE", "ITEM_PETALBURG_CITY_MAX_REVIVE" }, + [1040] = { "FLAG_ITEM_PETALBURG_CITY_ETHER", "ITEM_PETALBURG_CITY_ETHER" }, + [1041] = { "FLAG_ITEM_RUSTBORO_CITY_X_DEFEND", "ITEM_RUSTBORO_CITY_X_DEFEND" }, + [1042] = { "FLAG_ITEM_LILYCOVE_CITY_MAX_REPEL", "ITEM_LILYCOVE_CITY_MAX_REPEL" }, + [1043] = { "FLAG_ITEM_MOSSDEEP_CITY_NET_BALL", "ITEM_MOSSDEEP_CITY_NET_BALL" }, + [1044] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_TM_IRON_TAIL", "ITEM_METEOR_FALLS_1F_1R_TM_IRON_TAIL" }, + [1045] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_FULL_HEAL", "ITEM_METEOR_FALLS_1F_1R_FULL_HEAL" }, + [1046] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_MOON_STONE", "ITEM_METEOR_FALLS_1F_1R_MOON_STONE" }, + [1047] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_PP_UP", "ITEM_METEOR_FALLS_1F_1R_PP_UP" }, + [1048] = { "FLAG_ITEM_RUSTURF_TUNNEL_POKE_BALL", "ITEM_RUSTURF_TUNNEL_POKE_BALL" }, + [1049] = { "FLAG_ITEM_RUSTURF_TUNNEL_MAX_ETHER", "ITEM_RUSTURF_TUNNEL_MAX_ETHER" }, + [1050] = { "FLAG_ITEM_GRANITE_CAVE_1F_ESCAPE_ROPE", "ITEM_GRANITE_CAVE_1F_ESCAPE_ROPE" }, + [1051] = { "FLAG_ITEM_GRANITE_CAVE_B1F_POKE_BALL", "ITEM_GRANITE_CAVE_B1F_POKE_BALL" }, + [1052] = { "FLAG_ITEM_MT_PYRE_5F_LAX_INCENSE", "ITEM_MT_PYRE_5F_LAX_INCENSE" }, + [1053] = { "FLAG_ITEM_GRANITE_CAVE_B2F_REPEL", "ITEM_GRANITE_CAVE_B2F_REPEL" }, + [1054] = { "FLAG_ITEM_GRANITE_CAVE_B2F_RARE_CANDY", "ITEM_GRANITE_CAVE_B2F_RARE_CANDY" }, + [1055] = { "FLAG_ITEM_PETALBURG_WOODS_X_ATTACK", "ITEM_PETALBURG_WOODS_X_ATTACK" }, + [1056] = { "FLAG_ITEM_PETALBURG_WOODS_GREAT_BALL", "ITEM_PETALBURG_WOODS_GREAT_BALL" }, + [1057] = { "FLAG_ITEM_ROUTE_104_POKE_BALL", "ITEM_ROUTE_104_POKE_BALL" }, + [1058] = { "FLAG_ITEM_PETALBURG_WOODS_ETHER", "ITEM_PETALBURG_WOODS_ETHER" }, + [1059] = { "FLAG_ITEM_MAGMA_HIDEOUT_3F_3R_ECAPE_ROPE", "ITEM_MAGMA_HIDEOUT_3F_3R_ECAPE_ROPE" }, + [1060] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_1_ORANGE_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_1_ORANGE_MAIL" }, + [1061] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_2_HARBOR_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_2_HARBOR_MAIL" }, + [1062] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_2_WAVE_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_2_WAVE_MAIL" }, + [1063] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_3_SHADOW_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_3_SHADOW_MAIL" }, + [1064] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_3_WOOD_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_3_WOOD_MAIL" }, + [1065] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_4_MECH_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_4_MECH_MAIL" }, + [1066] = { "FLAG_ITEM_ROUTE_124_YELLOW_SHARD", "ITEM_ROUTE_124_YELLOW_SHARD" }, + [1067] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_6_GLITTER_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_6_GLITTER_MAIL" }, + [1068] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_7_TROPIC_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_7_TROPIC_MAIL" }, + [1069] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_8_BEAD_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_8_BEAD_MAIL" }, + [1070] = { "FLAG_ITEM_JAGGED_PASS_BURN_HEAL", "ITEM_JAGGED_PASS_BURN_HEAL" }, + [1071] = { "FLAG_ITEM_AQUA_HIDEOUT_B1F_MAX_ELIXIR", "ITEM_AQUA_HIDEOUT_B1F_MAX_ELIXIR" }, + [1072] = { "FLAG_ITEM_AQUA_HIDEOUT_B2F_NEST_BALL", "ITEM_AQUA_HIDEOUT_B2F_NEST_BALL" }, + [1073] = { "FLAG_ITEM_MT_PYRE_EXTERIOR_MAX_POTION", "ITEM_MT_PYRE_EXTERIOR_MAX_POTION" }, + [1074] = { "FLAG_ITEM_MT_PYRE_EXTERIOR_TM_SKILL_SWAP", "ITEM_MT_PYRE_EXTERIOR_TM_SKILL_SWAP" }, + [1075] = { "FLAG_ITEM_NEW_MAUVILLE_ULTRA_BALL", "ITEM_NEW_MAUVILLE_ULTRA_BALL" }, + [1076] = { "FLAG_ITEM_NEW_MAUVILLE_ESCAPE_ROPE", "ITEM_NEW_MAUVILLE_ESCAPE_ROPE" }, + [1077] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_6_LUXURY_BALL", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_6_LUXURY_BALL" }, + [1078] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_2_SCANNER", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_2_SCANNER" }, + [1079] = { "FLAG_ITEM_SCORCHED_SLAB_TM_SUNNY_DAY", "ITEM_SCORCHED_SLAB_TM_SUNNY_DAY" }, + [1080] = { "FLAG_ITEM_METEOR_FALLS_B1F_2R_TM_DRAGON_CLAW", "ITEM_METEOR_FALLS_B1F_2R_TM_DRAGON_CLAW" }, + [1081] = { "FLAG_ITEM_SHOAL_CAVE_ENTRANCE_BIG_PEARL", "ITEM_SHOAL_CAVE_ENTRANCE_BIG_PEARL" }, + [1082] = { "FLAG_ITEM_SHOAL_CAVE_INNER_ROOM_RARE_CANDY", "ITEM_SHOAL_CAVE_INNER_ROOM_RARE_CANDY" }, + [1083] = { "FLAG_ITEM_SHOAL_CAVE_STAIRS_ROOM_ICE_HEAL", "ITEM_SHOAL_CAVE_STAIRS_ROOM_ICE_HEAL" }, + [1084] = { "FLAG_ITEM_VICTORY_ROAD_1F_MAX_ELIXIR", "ITEM_VICTORY_ROAD_1F_MAX_ELIXIR" }, + [1085] = { "FLAG_ITEM_VICTORY_ROAD_1F_PP_UP", "ITEM_VICTORY_ROAD_1F_PP_UP" }, + [1086] = { "FLAG_ITEM_VICTORY_ROAD_B1F_TM_PSYCHIC", "ITEM_VICTORY_ROAD_B1F_TM_PSYCHIC" }, + [1087] = { "FLAG_ITEM_VICTORY_ROAD_B1F_FULL_RESTORE", "ITEM_VICTORY_ROAD_B1F_FULL_RESTORE" }, + [1088] = { "FLAG_ITEM_VICTORY_ROAD_B2F_FULL_HEAL", "ITEM_VICTORY_ROAD_B2F_FULL_HEAL" }, + [1089] = { "FLAG_ITEM_MT_PYRE_6F_TM_SHADOW_BALL", "ITEM_MT_PYRE_6F_TM_SHADOW_BALL" }, + [1090] = { "FLAG_ITEM_SEAFLOOR_CAVERN_ROOM_9_TM_EARTHQUAKE", "ITEM_SEAFLOOR_CAVERN_ROOM_9_TM_EARTHQUAKE" }, + [1091] = { "FLAG_ITEM_FIERY_PATH_TM_TOXIC", "ITEM_FIERY_PATH_TM_TOXIC" }, + [1092] = { "FLAG_ITEM_ROUTE_124_RED_SHARD", "ITEM_ROUTE_124_RED_SHARD" }, + [1093] = { "FLAG_ITEM_ROUTE_124_BLUE_SHARD", "ITEM_ROUTE_124_BLUE_SHARD" }, + [1094] = { "FLAG_ITEM_SAFARI_ZONE_NORTH_WEST_TM_SOLAR_BEAM", "ITEM_SAFARI_ZONE_NORTH_WEST_TM_SOLAR_BEAM" }, + [1095] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_1F_HARBOR_MAIL", "ITEM_ABANDONED_SHIP_ROOMS_1F_HARBOR_MAIL" }, + [1096] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_B1F_ESCAPE_ROPE", "ITEM_ABANDONED_SHIP_ROOMS_B1F_ESCAPE_ROPE" }, + [1097] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_2_B1F_DIVE_BALL", "ITEM_ABANDONED_SHIP_ROOMS_2_B1F_DIVE_BALL" }, + [1098] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_B1F_TM_ICE_BEAM", "ITEM_ABANDONED_SHIP_ROOMS_B1F_TM_ICE_BEAM" }, + [1099] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_2_1F_REVIVE", "ITEM_ABANDONED_SHIP_ROOMS_2_1F_REVIVE" }, + [1100] = { "FLAG_ITEM_ABANDONED_SHIP_CAPTAINS_OFFICE_STORAGE_KEY", "ITEM_ABANDONED_SHIP_CAPTAINS_OFFICE_STORAGE_KEY" }, + [1101] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_3_WATER_STONE", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_3_WATER_STONE" }, + [1102] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_1_TM_RAIN_DANCE", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_1_TM_RAIN_DANCE" }, + [1103] = { "FLAG_ITEM_ROUTE_121_CARBOS", "ITEM_ROUTE_121_CARBOS" }, + [1104] = { "FLAG_ITEM_ROUTE_123_ULTRA_BALL", "ITEM_ROUTE_123_ULTRA_BALL" }, + [1105] = { "FLAG_ITEM_ROUTE_126_GREEN_SHARD", "ITEM_ROUTE_126_GREEN_SHARD" }, + [1106] = { "FLAG_ITEM_ROUTE_119_HYPER_POTION_2", "ITEM_ROUTE_119_HYPER_POTION_2" }, + [1107] = { "FLAG_ITEM_ROUTE_120_HYPER_POTION", "ITEM_ROUTE_120_HYPER_POTION" }, + [1108] = { "FLAG_ITEM_ROUTE_120_NEST_BALL", "ITEM_ROUTE_120_NEST_BALL" }, + [1109] = { "FLAG_ITEM_ROUTE_123_ELIXIR", "ITEM_ROUTE_123_ELIXIR" }, + [1110] = { "FLAG_ITEM_NEW_MAUVILLE_THUNDER_STONE", "ITEM_NEW_MAUVILLE_THUNDER_STONE" }, + [1111] = { "FLAG_ITEM_FIERY_PATH_FIRE_STONE", "ITEM_FIERY_PATH_FIRE_STONE" }, + [1112] = { "FLAG_ITEM_SHOAL_CAVE_ICE_ROOM_TM_HAIL", "ITEM_SHOAL_CAVE_ICE_ROOM_TM_HAIL" }, + [1113] = { "FLAG_ITEM_SHOAL_CAVE_ICE_ROOM_NEVER_MELT_ICE", "ITEM_SHOAL_CAVE_ICE_ROOM_NEVER_MELT_ICE" }, + [1114] = { "FLAG_ITEM_ROUTE_103_GUARD_SPEC", "ITEM_ROUTE_103_GUARD_SPEC" }, + [1115] = { "FLAG_ITEM_ROUTE_104_X_ACCURACY", "ITEM_ROUTE_104_X_ACCURACY" }, + [1116] = { "FLAG_ITEM_MAUVILLE_CITY_X_SPEED", "ITEM_MAUVILLE_CITY_X_SPEED" }, + [1117] = { "FLAG_ITEM_PETALBURG_WOODS_PARALYZE_HEAL", "ITEM_PETALBURG_WOODS_PARALYZE_HEAL" }, + [1118] = { "FLAG_ITEM_ROUTE_115_GREAT_BALL", "ITEM_ROUTE_115_GREAT_BALL" }, + [1119] = { "FLAG_ITEM_SAFARI_ZONE_NORTH_CALCIUM", "ITEM_SAFARI_ZONE_NORTH_CALCIUM" }, + [1120] = { "FLAG_ITEM_MT_PYRE_3F_SUPER_REPEL", "ITEM_MT_PYRE_3F_SUPER_REPEL" }, + [1121] = { "FLAG_ITEM_ROUTE_118_HYPER_POTION", "ITEM_ROUTE_118_HYPER_POTION" }, + [1122] = { "FLAG_ITEM_NEW_MAUVILLE_FULL_HEAL", "ITEM_NEW_MAUVILLE_FULL_HEAL" }, + [1123] = { "FLAG_ITEM_NEW_MAUVILLE_PARALYZE_HEAL", "ITEM_NEW_MAUVILLE_PARALYZE_HEAL" }, + [1124] = { "FLAG_ITEM_AQUA_HIDEOUT_B1F_MASTER_BALL", "ITEM_AQUA_HIDEOUT_B1F_MASTER_BALL" }, + [1125] = { "FLAG_ITEM_OLD_MAGMA_HIDEOUT_B1F_MASTER_BALL", "ITEM_OLD_MAGMA_HIDEOUT_B1F_MASTER_BALL" }, + [1126] = { "FLAG_ITEM_OLD_MAGMA_HIDEOUT_B1F_MAX_ELIXIR", "ITEM_OLD_MAGMA_HIDEOUT_B1F_MAX_ELIXIR" }, + [1127] = { "FLAG_ITEM_OLD_MAGMA_HIDEOUT_B2F_NEST_BALL", "ITEM_OLD_MAGMA_HIDEOUT_B2F_NEST_BALL" }, + [1128] = { "FLAG_UNUSED_0x468", "UNUSED_0x468" }, + [1129] = { "FLAG_ITEM_MT_PYRE_2F_ULTRA_BALL", "ITEM_MT_PYRE_2F_ULTRA_BALL" }, + [1130] = { "FLAG_ITEM_MT_PYRE_4F_SEA_INCENSE", "ITEM_MT_PYRE_4F_SEA_INCENSE" }, + [1131] = { "FLAG_ITEM_SAFARI_ZONE_SOUTH_WEST_MAX_REVIVE", "ITEM_SAFARI_ZONE_SOUTH_WEST_MAX_REVIVE" }, + [1132] = { "FLAG_ITEM_AQUA_HIDEOUT_B1F_NUGGET", "ITEM_AQUA_HIDEOUT_B1F_NUGGET" }, + [1133] = { "FLAG_ITEM_MOSSDEEP_STEVENS_HOUSE_HM08", "ITEM_MOSSDEEP_STEVENS_HOUSE_HM08" }, + [1134] = { "FLAG_ITEM_ROUTE_119_NUGGET", "ITEM_ROUTE_119_NUGGET" }, + [1135] = { "FLAG_ITEM_ROUTE_104_POTION", "ITEM_ROUTE_104_POTION" }, + [1136] = { "FLAG_UNUSED_0x470", "UNUSED_0x470" }, + [1137] = { "FLAG_ITEM_ROUTE_103_PP_UP", "ITEM_ROUTE_103_PP_UP" }, + [1138] = { "FLAG_UNUSED_0x472", "UNUSED_0x472" }, + [1139] = { "FLAG_ITEM_ROUTE_108_STAR_PIECE", "ITEM_ROUTE_108_STAR_PIECE" }, + [1140] = { "FLAG_ITEM_ROUTE_109_POTION", "ITEM_ROUTE_109_POTION" }, + [1141] = { "FLAG_ITEM_ROUTE_110_ELIXIR", "ITEM_ROUTE_110_ELIXIR" }, + [1142] = { "FLAG_ITEM_ROUTE_111_ELIXIR", "ITEM_ROUTE_111_ELIXIR" }, + [1143] = { "FLAG_ITEM_ROUTE_113_HYPER_POTION", "ITEM_ROUTE_113_HYPER_POTION" }, + [1144] = { "FLAG_ITEM_ROUTE_115_HEAL_POWDER", "ITEM_ROUTE_115_HEAL_POWDER" }, + [1145] = { "FLAG_UNUSED_0x479", "UNUSED_0x479" }, + [1146] = { "FLAG_ITEM_ROUTE_116_POTION", "ITEM_ROUTE_116_POTION" }, + [1147] = { "FLAG_ITEM_ROUTE_119_ELIXIR_2", "ITEM_ROUTE_119_ELIXIR_2" }, + [1148] = { "FLAG_ITEM_ROUTE_120_REVIVE", "ITEM_ROUTE_120_REVIVE" }, + [1149] = { "FLAG_ITEM_ROUTE_121_REVIVE", "ITEM_ROUTE_121_REVIVE" }, + [1150] = { "FLAG_ITEM_ROUTE_121_ZINC", "ITEM_ROUTE_121_ZINC" }, + [1151] = { "FLAG_ITEM_MAGMA_HIDEOUT_1F_RARE_CANDY", "ITEM_MAGMA_HIDEOUT_1F_RARE_CANDY" }, + [1152] = { "FLAG_ITEM_ROUTE_123_PP_UP", "ITEM_ROUTE_123_PP_UP" }, + [1153] = { "FLAG_ITEM_ROUTE_123_REVIVAL_HERB", "ITEM_ROUTE_123_REVIVAL_HERB" }, + [1154] = { "FLAG_ITEM_ROUTE_125_BIG_PEARL", "ITEM_ROUTE_125_BIG_PEARL" }, + [1155] = { "FLAG_ITEM_ROUTE_127_RARE_CANDY", "ITEM_ROUTE_127_RARE_CANDY" }, + [1156] = { "FLAG_ITEM_ROUTE_132_PROTEIN", "ITEM_ROUTE_132_PROTEIN" }, + [1157] = { "FLAG_ITEM_ROUTE_133_MAX_REVIVE", "ITEM_ROUTE_133_MAX_REVIVE" }, + [1158] = { "FLAG_ITEM_ROUTE_134_CARBOS", "ITEM_ROUTE_134_CARBOS" }, + [1159] = { "FLAG_ITEM_ROUTE_134_STAR_PIECE", "ITEM_ROUTE_134_STAR_PIECE" }, + [1160] = { "FLAG_ITEM_ROUTE_114_ENERGY_POWDER", "ITEM_ROUTE_114_ENERGY_POWDER" }, + [1161] = { "FLAG_ITEM_ROUTE_115_PP_UP", "ITEM_ROUTE_115_PP_UP" }, + [1162] = { "FLAG_ITEM_ARTISAN_CAVE_B1F_HP_UP", "ITEM_ARTISAN_CAVE_B1F_HP_UP" }, + [1163] = { "FLAG_ITEM_ARTISAN_CAVE_1F_CARBOS", "ITEM_ARTISAN_CAVE_1F_CARBOS" }, + [1164] = { "FLAG_ITEM_MAGMA_HIDEOUT_2F_2R_MAX_ELIXIR", "ITEM_MAGMA_HIDEOUT_2F_2R_MAX_ELIXIR" }, + [1165] = { "FLAG_ITEM_MAGMA_HIDEOUT_2F_2R_FULL_RESTORE", "ITEM_MAGMA_HIDEOUT_2F_2R_FULL_RESTORE" }, + [1166] = { "FLAG_ITEM_MAGMA_HIDEOUT_3F_1R_NUGGET", "ITEM_MAGMA_HIDEOUT_3F_1R_NUGGET" }, + [1167] = { "FLAG_ITEM_MAGMA_HIDEOUT_3F_2R_PP_MAX", "ITEM_MAGMA_HIDEOUT_3F_2R_PP_MAX" }, + [1168] = { "FLAG_ITEM_MAGMA_HIDEOUT_4F_MAX_REVIVE", "ITEM_MAGMA_HIDEOUT_4F_MAX_REVIVE" }, + [1169] = { "FLAG_ITEM_SAFARI_ZONE_NORTH_EAST_NUGGET", "ITEM_SAFARI_ZONE_NORTH_EAST_NUGGET" }, + [1170] = { "FLAG_ITEM_SAFARI_ZONE_SOUTH_EAST_BIG_PEARL", "ITEM_SAFARI_ZONE_SOUTH_EAST_BIG_PEARL" }, + [1171] = { "FLAG_UNUSED_0x493", "UNUSED_0x493" }, + [1172] = { "FLAG_UNUSED_0x494", "UNUSED_0x494" }, + [1173] = { "FLAG_UNUSED_0x495", "UNUSED_0x495" }, + [1174] = { "FLAG_UNUSED_0x496", "UNUSED_0x496" }, + [1175] = { "FLAG_UNUSED_0x497", "UNUSED_0x497" }, + [1176] = { "FLAG_UNUSED_0x498", "UNUSED_0x498" }, + [1177] = { "FLAG_UNUSED_0x499", "UNUSED_0x499" }, + [1178] = { "FLAG_UNUSED_0x49A", "UNUSED_0x49A" }, + [1179] = { "FLAG_UNUSED_0x49B", "UNUSED_0x49B" }, + [1180] = { "FLAG_UNUSED_0x49C", "UNUSED_0x49C" }, + [1181] = { "FLAG_UNUSED_0x49D", "UNUSED_0x49D" }, + [1182] = { "FLAG_UNUSED_0x49E", "UNUSED_0x49E" }, + [1183] = { "FLAG_UNUSED_0x49F", "UNUSED_0x49F" }, + [1184] = { "FLAG_UNUSED_0x4A0", "UNUSED_0x4A0" }, + [1185] = { "FLAG_UNUSED_0x4A1", "UNUSED_0x4A1" }, + [1186] = { "FLAG_UNUSED_0x4A2", "UNUSED_0x4A2" }, + [1187] = { "FLAG_UNUSED_0x4A3", "UNUSED_0x4A3" }, + [1188] = { "FLAG_UNUSED_0x4A4", "UNUSED_0x4A4" }, + [1189] = { "FLAG_UNUSED_0x4A5", "UNUSED_0x4A5" }, + [1190] = { "FLAG_UNUSED_0x4A6", "UNUSED_0x4A6" }, + [1191] = { "FLAG_UNUSED_0x4A7", "UNUSED_0x4A7" }, + [1192] = { "FLAG_UNUSED_0x4A8", "UNUSED_0x4A8" }, + [1193] = { "FLAG_UNUSED_0x4A9", "UNUSED_0x4A9" }, + [1194] = { "FLAG_UNUSED_0x4AA", "UNUSED_0x4AA" }, + [1195] = { "FLAG_UNUSED_0x4AB", "UNUSED_0x4AB" }, + [1196] = { "FLAG_UNUSED_0x4AC", "UNUSED_0x4AC" }, + [1197] = { "FLAG_UNUSED_0x4AD", "UNUSED_0x4AD" }, + [1198] = { "FLAG_UNUSED_0x4AE", "UNUSED_0x4AE" }, + [1199] = { "FLAG_UNUSED_0x4AF", "UNUSED_0x4AF" }, + [1200] = { "FLAG_UNUSED_0x4B0", "UNUSED_0x4B0" }, + [1201] = { "FLAG_UNUSED_0x4B1", "UNUSED_0x4B1" }, + [1202] = { "FLAG_UNUSED_0x4B2", "UNUSED_0x4B2" }, + [1203] = { "FLAG_UNUSED_0x4B3", "UNUSED_0x4B3" }, + [1204] = { "FLAG_UNUSED_0x4B4", "UNUSED_0x4B4" }, + [1205] = { "FLAG_UNUSED_0x4B5", "UNUSED_0x4B5" }, + [1206] = { "FLAG_UNUSED_0x4B6", "UNUSED_0x4B6" }, + [1207] = { "FLAG_UNUSED_0x4B7", "UNUSED_0x4B7" }, + [1208] = { "FLAG_UNUSED_0x4B8", "UNUSED_0x4B8" }, + [1209] = { "FLAG_UNUSED_0x4B9", "UNUSED_0x4B9" }, + [1210] = { "FLAG_UNUSED_0x4BA", "UNUSED_0x4BA" }, + [1211] = { "FLAG_UNUSED_0x4BB", "UNUSED_0x4BB" }, + [1212] = { "FLAG_UNUSED_0x4BC", "UNUSED_0x4BC" }, + [1213] = { "FLAG_UNUSED_0x4BD", "UNUSED_0x4BD" }, + [1214] = { "FLAG_UNUSED_0x4BE", "UNUSED_0x4BE" }, + [1215] = { "FLAG_UNUSED_0x4BF", "UNUSED_0x4BF" }, + [1216] = { "FLAG_UNUSED_0x4C0", "UNUSED_0x4C0" }, + [1217] = { "FLAG_UNUSED_0x4C1", "UNUSED_0x4C1" }, + [1218] = { "FLAG_UNUSED_0x4C2", "UNUSED_0x4C2" }, + [1219] = { "FLAG_UNUSED_0x4C3", "UNUSED_0x4C3" }, + [1220] = { "FLAG_UNUSED_0x4C4", "UNUSED_0x4C4" }, + [1221] = { "FLAG_UNUSED_0x4C5", "UNUSED_0x4C5" }, + [1222] = { "FLAG_UNUSED_0x4C6", "UNUSED_0x4C6" }, + [1223] = { "FLAG_UNUSED_0x4C7", "UNUSED_0x4C7" }, + [1224] = { "FLAG_UNUSED_0x4C8", "UNUSED_0x4C8" }, + [1225] = { "FLAG_UNUSED_0x4C9", "UNUSED_0x4C9" }, + [1226] = { "FLAG_UNUSED_0x4CA", "UNUSED_0x4CA" }, + [1227] = { "FLAG_UNUSED_0x4CB", "UNUSED_0x4CB" }, + [1228] = { "FLAG_UNUSED_0x4CC", "UNUSED_0x4CC" }, + [1229] = { "FLAG_UNUSED_0x4CD", "UNUSED_0x4CD" }, + [1230] = { "FLAG_UNUSED_0x4CE", "UNUSED_0x4CE" }, + [1231] = { "FLAG_UNUSED_0x4CF", "UNUSED_0x4CF" }, + [1232] = { "FLAG_UNUSED_0x4D0", "UNUSED_0x4D0" }, + [1233] = { "FLAG_UNUSED_0x4D1", "UNUSED_0x4D1" }, + [1234] = { "FLAG_UNUSED_0x4D2", "UNUSED_0x4D2" }, + [1235] = { "FLAG_UNUSED_0x4D3", "UNUSED_0x4D3" }, + [1236] = { "FLAG_UNUSED_0x4D4", "UNUSED_0x4D4" }, + [1237] = { "FLAG_UNUSED_0x4D5", "UNUSED_0x4D5" }, + [1238] = { "FLAG_UNUSED_0x4D6", "UNUSED_0x4D6" }, + [1239] = { "FLAG_UNUSED_0x4D7", "UNUSED_0x4D7" }, + [1240] = { "FLAG_UNUSED_0x4D8", "UNUSED_0x4D8" }, + [1241] = { "FLAG_UNUSED_0x4D9", "UNUSED_0x4D9" }, + [1242] = { "FLAG_UNUSED_0x4DA", "UNUSED_0x4DA" }, + [1243] = { "FLAG_UNUSED_0x4DB", "UNUSED_0x4DB" }, + [1244] = { "FLAG_UNUSED_0x4DC", "UNUSED_0x4DC" }, + [1245] = { "FLAG_UNUSED_0x4DD", "UNUSED_0x4DD" }, + [1246] = { "FLAG_UNUSED_0x4DE", "UNUSED_0x4DE" }, + [1247] = { "FLAG_UNUSED_0x4DF", "UNUSED_0x4DF" }, + [1248] = { "FLAG_UNUSED_0x4E0", "UNUSED_0x4E0" }, + [1249] = { "FLAG_UNUSED_0x4E1", "UNUSED_0x4E1" }, + [1250] = { "FLAG_UNUSED_0x4E2", "UNUSED_0x4E2" }, + [1251] = { "FLAG_UNUSED_0x4E3", "UNUSED_0x4E3" }, + [1252] = { "FLAG_UNUSED_0x4E4", "UNUSED_0x4E4" }, + [1253] = { "FLAG_UNUSED_0x4E5", "UNUSED_0x4E5" }, + [1254] = { "FLAG_UNUSED_0x4E6", "UNUSED_0x4E6" }, + [1255] = { "FLAG_UNUSED_0x4E7", "UNUSED_0x4E7" }, + [1256] = { "FLAG_UNUSED_0x4E8", "UNUSED_0x4E8" }, + [1257] = { "FLAG_UNUSED_0x4E9", "UNUSED_0x4E9" }, + [1258] = { "FLAG_UNUSED_0x4EA", "UNUSED_0x4EA" }, + [1259] = { "FLAG_UNUSED_0x4EB", "UNUSED_0x4EB" }, + [1260] = { "FLAG_UNUSED_0x4EC", "UNUSED_0x4EC" }, + [1261] = { "FLAG_UNUSED_0x4ED", "UNUSED_0x4ED" }, + [1262] = { "FLAG_UNUSED_0x4EE", "UNUSED_0x4EE" }, + [1263] = { "FLAG_UNUSED_0x4EF", "UNUSED_0x4EF" }, + [1264] = { "FLAG_DEFEATED_RUSTBORO_GYM", "DEFEATED_RUSTBORO_GYM" }, + [1265] = { "FLAG_DEFEATED_DEWFORD_GYM", "DEFEATED_DEWFORD_GYM" }, + [1266] = { "FLAG_DEFEATED_MAUVILLE_GYM", "DEFEATED_MAUVILLE_GYM" }, + [1267] = { "FLAG_DEFEATED_LAVARIDGE_GYM", "DEFEATED_LAVARIDGE_GYM" }, + [1268] = { "FLAG_DEFEATED_PETALBURG_GYM", "DEFEATED_PETALBURG_GYM" }, + [1269] = { "FLAG_DEFEATED_FORTREE_GYM", "DEFEATED_FORTREE_GYM" }, + [1270] = { "FLAG_DEFEATED_MOSSDEEP_GYM", "DEFEATED_MOSSDEEP_GYM" }, + [1271] = { "FLAG_DEFEATED_SOOTOPOLIS_GYM", "DEFEATED_SOOTOPOLIS_GYM" }, + [1272] = { "FLAG_DEFEATED_METEOR_FALLS_STEVEN", "DEFEATED_METEOR_FALLS_STEVEN" }, + [1273] = { "FLAG_UNUSED_0x4F9", "UNUSED_0x4F9" }, + [1274] = { "FLAG_UNUSED_0x4FA", "UNUSED_0x4FA" }, + [1275] = { "FLAG_DEFEATED_ELITE_4_SIDNEY", "DEFEATED_ELITE_4_SIDNEY" }, + [1276] = { "FLAG_DEFEATED_ELITE_4_PHOEBE", "DEFEATED_ELITE_4_PHOEBE" }, + [1277] = { "FLAG_DEFEATED_ELITE_4_GLACIA", "DEFEATED_ELITE_4_GLACIA" }, + [1278] = { "FLAG_DEFEATED_ELITE_4_DRAKE", "DEFEATED_ELITE_4_DRAKE" }, + [1279] = { "FLAG_UNUSED_0x4FF", "UNUSED_0x4FF" }, + [1280] = { false, "TRAINER_FLAGS_START" }, + [2143] = { false, "TRAINER_FLAGS_END" }, + [2144] = { "FLAG_SYS_POKEMON_GET", "SYSTEM_FLAGS", "SYS_POKEMON_GET" }, + [2145] = { "FLAG_SYS_POKEDEX_GET", "SYS_POKEDEX_GET" }, + [2146] = { "FLAG_SYS_POKENAV_GET", "SYS_POKENAV_GET" }, + [2147] = { "FLAG_UNUSED_0x863", "UNUSED_0x863" }, + [2148] = { "FLAG_SYS_GAME_CLEAR", "SYS_GAME_CLEAR" }, + [2149] = { "FLAG_SYS_CHAT_USED", "SYS_CHAT_USED" }, + [2150] = { "FLAG_UNLOCKED_TRENDY_SAYINGS", "UNLOCKED_TRENDY_SAYINGS" }, + [2151] = { "FLAG_BADGE01_GET", "BADGE01_GET" }, + [2152] = { "FLAG_BADGE02_GET", "BADGE02_GET" }, + [2153] = { "FLAG_BADGE03_GET", "BADGE03_GET" }, + [2154] = { "FLAG_BADGE04_GET", "BADGE04_GET" }, + [2155] = { "FLAG_BADGE05_GET", "BADGE05_GET" }, + [2156] = { "FLAG_BADGE06_GET", "BADGE06_GET" }, + [2157] = { "FLAG_BADGE07_GET", "BADGE07_GET" }, + [2158] = { "FLAG_BADGE08_GET", "BADGE08_GET" }, + [2159] = { "FLAG_VISITED_LITTLEROOT_TOWN", "VISITED_LITTLEROOT_TOWN" }, + [2160] = { "FLAG_VISITED_OLDALE_TOWN", "VISITED_OLDALE_TOWN" }, + [2161] = { "FLAG_VISITED_DEWFORD_TOWN", "VISITED_DEWFORD_TOWN" }, + [2162] = { "FLAG_VISITED_LAVARIDGE_TOWN", "VISITED_LAVARIDGE_TOWN" }, + [2163] = { "FLAG_VISITED_FALLARBOR_TOWN", "VISITED_FALLARBOR_TOWN" }, + [2164] = { "FLAG_VISITED_VERDANTURF_TOWN", "VISITED_VERDANTURF_TOWN" }, + [2165] = { "FLAG_VISITED_PACIFIDLOG_TOWN", "VISITED_PACIFIDLOG_TOWN" }, + [2166] = { "FLAG_VISITED_PETALBURG_CITY", "VISITED_PETALBURG_CITY" }, + [2167] = { "FLAG_VISITED_SLATEPORT_CITY", "VISITED_SLATEPORT_CITY" }, + [2168] = { "FLAG_VISITED_MAUVILLE_CITY", "VISITED_MAUVILLE_CITY" }, + [2169] = { "FLAG_VISITED_RUSTBORO_CITY", "VISITED_RUSTBORO_CITY" }, + [2170] = { "FLAG_VISITED_FORTREE_CITY", "VISITED_FORTREE_CITY" }, + [2171] = { "FLAG_VISITED_LILYCOVE_CITY", "VISITED_LILYCOVE_CITY" }, + [2172] = { "FLAG_VISITED_MOSSDEEP_CITY", "VISITED_MOSSDEEP_CITY" }, + [2173] = { "FLAG_VISITED_SOOTOPOLIS_CITY", "VISITED_SOOTOPOLIS_CITY" }, + [2174] = { "FLAG_VISITED_EVER_GRANDE_CITY", "VISITED_EVER_GRANDE_CITY" }, + [2175] = { "FLAG_IS_CHAMPION", "IS_CHAMPION" }, + [2176] = { "FLAG_NURSE_UNION_ROOM_REMINDER", "NURSE_UNION_ROOM_REMINDER" }, + [2177] = { "FLAG_UNUSED_0x881", "UNUSED_0x881" }, + [2178] = { "FLAG_UNUSED_0x882", "UNUSED_0x882" }, + [2179] = { "FLAG_UNUSED_0x883", "UNUSED_0x883" }, + [2180] = { "FLAG_UNUSED_0x884", "UNUSED_0x884" }, + [2181] = { "FLAG_UNUSED_0x885", "UNUSED_0x885" }, + [2182] = { "FLAG_UNUSED_0x886", "UNUSED_0x886" }, + [2183] = { "FLAG_UNUSED_0x887", "UNUSED_0x887" }, + [2184] = { "FLAG_SYS_USE_FLASH", "SYS_USE_FLASH" }, + [2185] = { "FLAG_SYS_USE_STRENGTH", "SYS_USE_STRENGTH" }, + [2186] = { "FLAG_SYS_WEATHER_CTRL", "SYS_WEATHER_CTRL" }, + [2187] = { "FLAG_SYS_CYCLING_ROAD", "SYS_CYCLING_ROAD" }, + [2188] = { "FLAG_SYS_SAFARI_MODE", "SYS_SAFARI_MODE" }, + [2189] = { "FLAG_SYS_CRUISE_MODE", "SYS_CRUISE_MODE" }, + [2190] = { "FLAG_UNUSED_0x88E", "UNUSED_0x88E" }, + [2191] = { "FLAG_UNUSED_0x88F", "UNUSED_0x88F" }, + [2192] = { "FLAG_SYS_TV_HOME", "SYS_TV_HOME" }, + [2193] = { "FLAG_SYS_TV_WATCH", "SYS_TV_WATCH" }, + [2194] = { false, "FLAG_SYS_TV_START", "SYS_TV_START" }, + [2195] = { "FLAG_SYS_CHANGED_DEWFORD_TREND", "SYS_CHANGED_DEWFORD_TREND" }, + [2196] = { "FLAG_SYS_MIX_RECORD", "SYS_MIX_RECORD" }, + [2197] = { "FLAG_SYS_CLOCK_SET", "SYS_CLOCK_SET" }, + [2198] = { "FLAG_SYS_NATIONAL_DEX", "SYS_NATIONAL_DEX" }, + [2199] = { "FLAG_SYS_CAVE_SHIP", "SYS_CAVE_SHIP" }, + [2200] = { "FLAG_SYS_CAVE_WONDER", "SYS_CAVE_WONDER" }, + [2201] = { "FLAG_SYS_CAVE_BATTLE", "SYS_CAVE_BATTLE" }, + [2202] = { "FLAG_SYS_SHOAL_TIDE", "SYS_SHOAL_TIDE" }, + [2203] = { "FLAG_SYS_RIBBON_GET", "SYS_RIBBON_GET" }, + [2204] = { "FLAG_LANDMARK_FLOWER_SHOP", "LANDMARK_FLOWER_SHOP" }, + [2205] = { "FLAG_LANDMARK_MR_BRINEY_HOUSE", "LANDMARK_MR_BRINEY_HOUSE" }, + [2206] = { "FLAG_LANDMARK_ABANDONED_SHIP", "LANDMARK_ABANDONED_SHIP" }, + [2207] = { "FLAG_LANDMARK_SEASHORE_HOUSE", "LANDMARK_SEASHORE_HOUSE" }, + [2208] = { "FLAG_LANDMARK_NEW_MAUVILLE", "LANDMARK_NEW_MAUVILLE" }, + [2209] = { "FLAG_LANDMARK_OLD_LADY_REST_SHOP", "LANDMARK_OLD_LADY_REST_SHOP" }, + [2210] = { "FLAG_LANDMARK_TRICK_HOUSE", "LANDMARK_TRICK_HOUSE" }, + [2211] = { "FLAG_LANDMARK_WINSTRATE_FAMILY", "LANDMARK_WINSTRATE_FAMILY" }, + [2212] = { "FLAG_LANDMARK_GLASS_WORKSHOP", "LANDMARK_GLASS_WORKSHOP" }, + [2213] = { "FLAG_LANDMARK_LANETTES_HOUSE", "LANDMARK_LANETTES_HOUSE" }, + [2214] = { "FLAG_LANDMARK_POKEMON_DAYCARE", "LANDMARK_POKEMON_DAYCARE" }, + [2215] = { "FLAG_LANDMARK_SEAFLOOR_CAVERN", "LANDMARK_SEAFLOOR_CAVERN" }, + [2216] = { "FLAG_LANDMARK_BATTLE_FRONTIER", "LANDMARK_BATTLE_FRONTIER" }, + [2217] = { "FLAG_LANDMARK_SOUTHERN_ISLAND", "LANDMARK_SOUTHERN_ISLAND" }, + [2218] = { "FLAG_LANDMARK_FIERY_PATH", "LANDMARK_FIERY_PATH" }, + [2219] = { "FLAG_SYS_PC_LANETTE", "SYS_PC_LANETTE" }, + [2220] = { "FLAG_SYS_MYSTERY_EVENT_ENABLE", "SYS_MYSTERY_EVENT_ENABLE" }, + [2221] = { "FLAG_SYS_ENC_UP_ITEM", "SYS_ENC_UP_ITEM" }, + [2222] = { "FLAG_SYS_ENC_DOWN_ITEM", "SYS_ENC_DOWN_ITEM" }, + [2223] = { "FLAG_SYS_BRAILLE_DIG", "SYS_BRAILLE_DIG" }, + [2224] = { "FLAG_SYS_REGIROCK_PUZZLE_COMPLETED", "SYS_REGIROCK_PUZZLE_COMPLETED" }, + [2225] = { "FLAG_SYS_BRAILLE_REGICE_COMPLETED", "SYS_BRAILLE_REGICE_COMPLETED" }, + [2226] = { "FLAG_SYS_REGISTEEL_PUZZLE_COMPLETED", "SYS_REGISTEEL_PUZZLE_COMPLETED" }, + [2227] = { "FLAG_ENABLE_SHIP_SOUTHERN_ISLAND", "ENABLE_SHIP_SOUTHERN_ISLAND" }, + [2228] = { "FLAG_LANDMARK_POKEMON_LEAGUE", "LANDMARK_POKEMON_LEAGUE" }, + [2229] = { "FLAG_LANDMARK_ISLAND_CAVE", "LANDMARK_ISLAND_CAVE" }, + [2230] = { "FLAG_LANDMARK_DESERT_RUINS", "LANDMARK_DESERT_RUINS" }, + [2231] = { "FLAG_LANDMARK_FOSSIL_MANIACS_HOUSE", "LANDMARK_FOSSIL_MANIACS_HOUSE" }, + [2232] = { "FLAG_LANDMARK_SCORCHED_SLAB", "LANDMARK_SCORCHED_SLAB" }, + [2233] = { "FLAG_LANDMARK_ANCIENT_TOMB", "LANDMARK_ANCIENT_TOMB" }, + [2234] = { "FLAG_LANDMARK_TUNNELERS_REST_HOUSE", "LANDMARK_TUNNELERS_REST_HOUSE" }, + [2235] = { "FLAG_LANDMARK_HUNTERS_HOUSE", "LANDMARK_HUNTERS_HOUSE" }, + [2236] = { "FLAG_LANDMARK_SEALED_CHAMBER", "LANDMARK_SEALED_CHAMBER" }, + [2237] = { "FLAG_SYS_TV_LATIAS_LATIOS", "SYS_TV_LATIAS_LATIOS" }, + [2238] = { "FLAG_LANDMARK_SKY_PILLAR", "LANDMARK_SKY_PILLAR" }, + [2239] = { "FLAG_SYS_SHOAL_ITEM", "SYS_SHOAL_ITEM" }, + [2240] = { "FLAG_SYS_B_DASH", "SYS_B_DASH" }, + [2241] = { "FLAG_SYS_CTRL_OBJ_DELETE", "SYS_CTRL_OBJ_DELETE" }, + [2242] = { "FLAG_SYS_RESET_RTC_ENABLE", "SYS_RESET_RTC_ENABLE" }, + [2243] = { "FLAG_LANDMARK_BERRY_MASTERS_HOUSE", "LANDMARK_BERRY_MASTERS_HOUSE" }, + [2244] = { "FLAG_SYS_TOWER_SILVER", "SYS_TOWER_SILVER" }, + [2245] = { "FLAG_SYS_TOWER_GOLD", "SYS_TOWER_GOLD" }, + [2246] = { "FLAG_SYS_DOME_SILVER", "SYS_DOME_SILVER" }, + [2247] = { "FLAG_SYS_DOME_GOLD", "SYS_DOME_GOLD" }, + [2248] = { "FLAG_SYS_PALACE_SILVER", "SYS_PALACE_SILVER" }, + [2249] = { "FLAG_SYS_PALACE_GOLD", "SYS_PALACE_GOLD" }, + [2250] = { "FLAG_SYS_ARENA_SILVER", "SYS_ARENA_SILVER" }, + [2251] = { "FLAG_SYS_ARENA_GOLD", "SYS_ARENA_GOLD" }, + [2252] = { "FLAG_SYS_FACTORY_SILVER", "SYS_FACTORY_SILVER" }, + [2253] = { "FLAG_SYS_FACTORY_GOLD", "SYS_FACTORY_GOLD" }, + [2254] = { "FLAG_SYS_PIKE_SILVER", "SYS_PIKE_SILVER" }, + [2255] = { "FLAG_SYS_PIKE_GOLD", "SYS_PIKE_GOLD" }, + [2256] = { "FLAG_SYS_PYRAMID_SILVER", "SYS_PYRAMID_SILVER" }, + [2257] = { "FLAG_SYS_PYRAMID_GOLD", "SYS_PYRAMID_GOLD" }, + [2258] = { "FLAG_SYS_FRONTIER_PASS", "SYS_FRONTIER_PASS" }, + [2259] = { "FLAG_MAP_SCRIPT_CHECKED_DEOXYS", "MAP_SCRIPT_CHECKED_DEOXYS" }, + [2260] = { "FLAG_DEOXYS_ROCK_COMPLETE", "DEOXYS_ROCK_COMPLETE" }, + [2261] = { "FLAG_ENABLE_SHIP_BIRTH_ISLAND", "ENABLE_SHIP_BIRTH_ISLAND" }, + [2262] = { "FLAG_ENABLE_SHIP_FARAWAY_ISLAND", "ENABLE_SHIP_FARAWAY_ISLAND" }, + [2263] = { "FLAG_SHOWN_BOX_WAS_FULL_MESSAGE", "SHOWN_BOX_WAS_FULL_MESSAGE" }, + [2264] = { "FLAG_ARRIVED_ON_FARAWAY_ISLAND", "ARRIVED_ON_FARAWAY_ISLAND" }, + [2265] = { "FLAG_ARRIVED_AT_MARINE_CAVE_EMERGE_SPOT", "ARRIVED_AT_MARINE_CAVE_EMERGE_SPOT" }, + [2266] = { "FLAG_ARRIVED_AT_TERRA_CAVE_ENTRANCE", "ARRIVED_AT_TERRA_CAVE_ENTRANCE" }, + [2267] = { "FLAG_SYS_MYSTERY_GIFT_ENABLE", "SYS_MYSTERY_GIFT_ENABLE" }, + [2268] = { "FLAG_ENTERED_MIRAGE_TOWER", "ENTERED_MIRAGE_TOWER" }, + [2269] = { "FLAG_LANDMARK_ALTERING_CAVE", "LANDMARK_ALTERING_CAVE" }, + [2270] = { "FLAG_LANDMARK_DESERT_UNDERPASS", "LANDMARK_DESERT_UNDERPASS" }, + [2271] = { "FLAG_LANDMARK_ARTISAN_CAVE", "LANDMARK_ARTISAN_CAVE" }, + [2272] = { "FLAG_ENABLE_SHIP_NAVEL_ROCK", "ENABLE_SHIP_NAVEL_ROCK" }, + [2273] = { "FLAG_ARRIVED_AT_NAVEL_ROCK", "ARRIVED_AT_NAVEL_ROCK" }, + [2274] = { "FLAG_LANDMARK_TRAINER_HILL", "LANDMARK_TRAINER_HILL" }, + [2275] = { "FLAG_UNUSED_0x8E3", "UNUSED_0x8E3" }, + [2276] = { "FLAG_RECEIVED_POKEDEX_FROM_BIRCH", "RECEIVED_POKEDEX_FROM_BIRCH" }, + [2277] = { "FLAG_UNUSED_0x8E5", "UNUSED_0x8E5" }, + [2278] = { "FLAG_UNUSED_0x8E6", "UNUSED_0x8E6" }, + [2279] = { "FLAG_UNUSED_0x8E7", "UNUSED_0x8E7" }, + [2280] = { "FLAG_UNUSED_0x8E8", "UNUSED_0x8E8" }, + [2281] = { "FLAG_UNUSED_0x8E9", "UNUSED_0x8E9" }, + [2282] = { "FLAG_UNUSED_0x8EA", "UNUSED_0x8EA" }, + [2283] = { "FLAG_UNUSED_0x8EB", "UNUSED_0x8EB" }, + [2284] = { "FLAG_UNUSED_0x8EC", "UNUSED_0x8EC" }, + [2285] = { "FLAG_UNUSED_0x8ED", "UNUSED_0x8ED" }, + [2286] = { "FLAG_UNUSED_0x8EE", "UNUSED_0x8EE" }, + [2287] = { "FLAG_UNUSED_0x8EF", "UNUSED_0x8EF" }, + [2288] = { "FLAG_UNUSED_0x8F0", "UNUSED_0x8F0" }, + [2289] = { "FLAG_UNUSED_0x8F1", "UNUSED_0x8F1" }, + [2290] = { "FLAG_UNUSED_0x8F2", "UNUSED_0x8F2" }, + [2291] = { "FLAG_UNUSED_0x8F3", "UNUSED_0x8F3" }, + [2292] = { "FLAG_UNUSED_0x8F4", "UNUSED_0x8F4" }, + [2293] = { "FLAG_UNUSED_0x8F5", "UNUSED_0x8F5" }, + [2294] = { "FLAG_UNUSED_0x8F6", "UNUSED_0x8F6" }, + [2295] = { "FLAG_UNUSED_0x8F7", "UNUSED_0x8F7" }, + [2296] = { "FLAG_UNUSED_0x8F8", "UNUSED_0x8F8" }, + [2297] = { "FLAG_UNUSED_0x8F9", "UNUSED_0x8F9" }, + [2298] = { "FLAG_UNUSED_0x8FA", "UNUSED_0x8FA" }, + [2299] = { "FLAG_UNUSED_0x8FB", "UNUSED_0x8FB" }, + [2300] = { "FLAG_UNUSED_0x8FC", "UNUSED_0x8FC" }, + [2301] = { "FLAG_UNUSED_0x8FD", "UNUSED_0x8FD" }, + [2302] = { "FLAG_UNUSED_0x8FE", "UNUSED_0x8FE" }, + [2303] = { "FLAG_UNUSED_0x8FF", "UNUSED_0x8FF" }, + [2304] = { "FLAG_UNUSED_0x900", "UNUSED_0x900" }, + [2305] = { "FLAG_UNUSED_0x901", "UNUSED_0x901" }, + [2306] = { "FLAG_UNUSED_0x902", "UNUSED_0x902" }, + [2307] = { "FLAG_UNUSED_0x903", "UNUSED_0x903" }, + [2308] = { "FLAG_UNUSED_0x904", "UNUSED_0x904" }, + [2309] = { "FLAG_UNUSED_0x905", "UNUSED_0x905" }, + [2310] = { "FLAG_UNUSED_0x906", "UNUSED_0x906" }, + [2311] = { "FLAG_UNUSED_0x907", "UNUSED_0x907" }, + [2312] = { "FLAG_UNUSED_0x908", "UNUSED_0x908" }, + [2313] = { "FLAG_UNUSED_0x909", "UNUSED_0x909" }, + [2314] = { "FLAG_UNUSED_0x90A", "UNUSED_0x90A" }, + [2315] = { "FLAG_UNUSED_0x90B", "UNUSED_0x90B" }, + [2316] = { "FLAG_UNUSED_0x90C", "UNUSED_0x90C" }, + [2317] = { "FLAG_UNUSED_0x90D", "UNUSED_0x90D" }, + [2318] = { "FLAG_UNUSED_0x90E", "UNUSED_0x90E" }, + [2319] = { "FLAG_UNUSED_0x90F", "UNUSED_0x90F" }, + [2320] = { "FLAG_UNUSED_0x910", "UNUSED_0x910" }, + [2321] = { "FLAG_UNUSED_0x911", "UNUSED_0x911" }, + [2322] = { "FLAG_UNUSED_0x912", "UNUSED_0x912" }, + [2323] = { "FLAG_UNUSED_0x913", "UNUSED_0x913" }, + [2324] = { "FLAG_UNUSED_0x914", "UNUSED_0x914" }, + [2325] = { "FLAG_UNUSED_0x915", "UNUSED_0x915" }, + [2326] = { "FLAG_UNUSED_0x916", "UNUSED_0x916" }, + [2327] = { "FLAG_UNUSED_0x917", "UNUSED_0x917" }, + [2328] = { "FLAG_UNUSED_0x918", "UNUSED_0x918" }, + [2329] = { "FLAG_UNUSED_0x919", "UNUSED_0x919" }, + [2330] = { "FLAG_UNUSED_0x91A", "UNUSED_0x91A" }, + [2331] = { "FLAG_UNUSED_0x91B", "UNUSED_0x91B" }, + [2332] = { "FLAG_UNUSED_0x91C", "UNUSED_0x91C" }, + [2333] = { "FLAG_UNUSED_0x91D", "UNUSED_0x91D" }, + [2334] = { "FLAG_UNUSED_0x91E", "UNUSED_0x91E" }, + [2335] = { "FLAG_UNUSED_0x91F", "UNUSED_0x91F" }, + [2336] = { "FLAG_UNUSED_0x920", "DAILY_FLAGS_START", "UNUSED_0x920" }, + [2337] = { "FLAG_DAILY_CONTEST_LOBBY_RECEIVED_BERRY", "DAILY_CONTEST_LOBBY_RECEIVED_BERRY" }, + [2338] = { "FLAG_DAILY_SECRET_BASE", "DAILY_SECRET_BASE" }, + [2339] = { "FLAG_UNUSED_0x923", "UNUSED_0x923" }, + [2340] = { "FLAG_UNUSED_0x924", "UNUSED_0x924" }, + [2341] = { "FLAG_UNUSED_0x925", "UNUSED_0x925" }, + [2342] = { "FLAG_UNUSED_0x926", "UNUSED_0x926" }, + [2343] = { "FLAG_UNUSED_0x927", "UNUSED_0x927" }, + [2344] = { "FLAG_UNUSED_0x928", "UNUSED_0x928" }, + [2345] = { "FLAG_UNUSED_0x929", "UNUSED_0x929" }, + [2346] = { "FLAG_DAILY_PICKED_LOTO_TICKET", "DAILY_PICKED_LOTO_TICKET" }, + [2347] = { "FLAG_DAILY_ROUTE_114_RECEIVED_BERRY", "DAILY_ROUTE_114_RECEIVED_BERRY" }, + [2348] = { "FLAG_DAILY_ROUTE_111_RECEIVED_BERRY", "DAILY_ROUTE_111_RECEIVED_BERRY" }, + [2349] = { "FLAG_DAILY_BERRY_MASTER_RECEIVED_BERRY", "DAILY_BERRY_MASTER_RECEIVED_BERRY" }, + [2350] = { "FLAG_DAILY_ROUTE_120_RECEIVED_BERRY", "DAILY_ROUTE_120_RECEIVED_BERRY" }, + [2351] = { "FLAG_DAILY_LILYCOVE_RECEIVED_BERRY", "DAILY_LILYCOVE_RECEIVED_BERRY" }, + [2352] = { "FLAG_DAILY_FLOWER_SHOP_RECEIVED_BERRY", "DAILY_FLOWER_SHOP_RECEIVED_BERRY" }, + [2353] = { "FLAG_DAILY_BERRY_MASTERS_WIFE", "DAILY_BERRY_MASTERS_WIFE" }, + [2354] = { "FLAG_DAILY_SOOTOPOLIS_RECEIVED_BERRY", "DAILY_SOOTOPOLIS_RECEIVED_BERRY" }, + [2355] = { "FLAG_UNUSED_0x933", "UNUSED_0x933" }, + [2356] = { "FLAG_DAILY_APPRENTICE_LEAVES", "DAILY_APPRENTICE_LEAVES" }, + [2357] = { "FLAG_UNUSED_0x935", "UNUSED_0x935" }, + [2358] = { "FLAG_UNUSED_0x936", "UNUSED_0x936" }, + [2359] = { "FLAG_UNUSED_0x937", "UNUSED_0x937" }, + [2360] = { "FLAG_UNUSED_0x938", "UNUSED_0x938" }, + [2361] = { "FLAG_UNUSED_0x939", "UNUSED_0x939" }, + [2362] = { "FLAG_UNUSED_0x93A", "UNUSED_0x93A" }, + [2363] = { "FLAG_UNUSED_0x93B", "UNUSED_0x93B" }, + [2364] = { "FLAG_UNUSED_0x93C", "UNUSED_0x93C" }, + [2365] = { "FLAG_UNUSED_0x93D", "UNUSED_0x93D" }, + [2366] = { "FLAG_UNUSED_0x93E", "UNUSED_0x93E" }, + [2367] = { "FLAG_UNUSED_0x93F", "UNUSED_0x93F" }, + [2368] = { "FLAG_UNUSED_0x940", "UNUSED_0x940" }, + [2369] = { "FLAG_UNUSED_0x941", "UNUSED_0x941" }, + [2370] = { "FLAG_UNUSED_0x942", "UNUSED_0x942" }, + [2371] = { "FLAG_UNUSED_0x943", "UNUSED_0x943" }, + [2372] = { "FLAG_UNUSED_0x944", "UNUSED_0x944" }, + [2373] = { "FLAG_UNUSED_0x945", "UNUSED_0x945" }, + [2374] = { "FLAG_UNUSED_0x946", "UNUSED_0x946" }, + [2375] = { "FLAG_UNUSED_0x947", "UNUSED_0x947" }, + [2376] = { "FLAG_UNUSED_0x948", "UNUSED_0x948" }, + [2377] = { "FLAG_UNUSED_0x949", "UNUSED_0x949" }, + [2378] = { "FLAG_UNUSED_0x94A", "UNUSED_0x94A" }, + [2379] = { "FLAG_UNUSED_0x94B", "UNUSED_0x94B" }, + [2380] = { "FLAG_UNUSED_0x94C", "UNUSED_0x94C" }, + [2381] = { "FLAG_UNUSED_0x94D", "UNUSED_0x94D" }, + [2382] = { "FLAG_UNUSED_0x94E", "UNUSED_0x94E" }, + [2383] = { "FLAG_UNUSED_0x94F", "UNUSED_0x94F" }, + [2384] = { "FLAG_UNUSED_0x950", "UNUSED_0x950" }, + [2385] = { "FLAG_UNUSED_0x951", "UNUSED_0x951" }, + [2386] = { "FLAG_UNUSED_0x952", "UNUSED_0x952" }, + [2387] = { "FLAG_UNUSED_0x953", "UNUSED_0x953" }, + [2388] = { "FLAG_UNUSED_0x954", "UNUSED_0x954" }, + [2389] = { "FLAG_UNUSED_0x955", "UNUSED_0x955" }, + [2390] = { "FLAG_UNUSED_0x956", "UNUSED_0x956" }, + [2391] = { "FLAG_UNUSED_0x957", "UNUSED_0x957" }, + [2392] = { "FLAG_UNUSED_0x958", "UNUSED_0x958" }, + [2393] = { "FLAG_UNUSED_0x959", "UNUSED_0x959" }, + [2394] = { "FLAG_UNUSED_0x95A", "UNUSED_0x95A" }, + [2395] = { "FLAG_UNUSED_0x95B", "UNUSED_0x95B" }, + [2396] = { "FLAG_UNUSED_0x95C", "UNUSED_0x95C" }, + [2397] = { "FLAG_UNUSED_0x95D", "UNUSED_0x95D" }, + [2398] = { "FLAG_UNUSED_0x95E", "UNUSED_0x95E" }, + [2399] = { "FLAG_UNUSED_0x95F", "DAILY_FLAGS_END", "UNUSED_0x95F" }, + }, +} diff --git a/src/mods/flag_names/firered.lua b/src/mods/flag_names/firered.lua new file mode 100644 index 000000000..4123b7412 --- /dev/null +++ b/src/mods/flag_names/firered.lua @@ -0,0 +1,1477 @@ +-- Generated flag identifiers only; no engine implementation. +-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 +-- Regenerate with tools/flag-catalog-check/generate.py. +return { + limit = 2304, + records = { + [1] = { "FLAG_TEMP_1", "TEMP_1" }, + [2] = { "FLAG_TEMP_2", "TEMP_2" }, + [3] = { "FLAG_TEMP_3", "TEMP_3" }, + [4] = { "FLAG_TEMP_4", "TEMP_4" }, + [5] = { "FLAG_TEMP_5", "TEMP_5" }, + [6] = { "FLAG_TEMP_6", "TEMP_6" }, + [7] = { "FLAG_TEMP_7", "TEMP_7" }, + [8] = { "FLAG_TEMP_8", "NUM_BADGES", "TEMP_8" }, + [9] = { "FLAG_TEMP_9", "TEMP_9" }, + [10] = { "FLAG_TEMP_A", "TEMP_A" }, + [11] = { "FLAG_TEMP_B", "TEMP_B" }, + [12] = { "FLAG_TEMP_C", "TEMP_C" }, + [13] = { "FLAG_TEMP_D", "TEMP_D" }, + [14] = { "FLAG_TEMP_E", "TEMP_E" }, + [15] = { "FLAG_TEMP_F", "TEMP_F" }, + [16] = { "FLAG_TEMP_10", "TEMP_10" }, + [17] = { "FLAG_TEMP_11", "TEMP_11" }, + [18] = { "FLAG_TEMP_12", "TEMP_12" }, + [19] = { "FLAG_TEMP_13", "TEMP_13" }, + [20] = { "FLAG_TEMP_14", "NUM_WONDER_CARD_FLAGS", "TEMP_14" }, + [21] = { "FLAG_TEMP_15", "TEMP_15" }, + [22] = { "FLAG_TEMP_16", "TEMP_16" }, + [23] = { "FLAG_TEMP_17", "TEMP_17" }, + [24] = { "FLAG_TEMP_18", "TEMP_18" }, + [25] = { "FLAG_TEMP_19", "TEMP_19" }, + [26] = { "FLAG_TEMP_1A", "TEMP_1A" }, + [27] = { "FLAG_TEMP_1B", "TEMP_1B" }, + [28] = { "FLAG_TEMP_1C", "TEMP_1C" }, + [29] = { "FLAG_TEMP_1D", "TEMP_1D" }, + [30] = { "FLAG_TEMP_1E", "TEMP_1E" }, + [31] = { "FLAG_TEMP_1F", "TEMP_1F", "TEMP_FLAGS_END" }, + [32] = { "FLAG_0x020", "0x020" }, + [33] = { "FLAG_0x021", "0x021" }, + [34] = { "FLAG_0x022", "0x022" }, + [35] = { "FLAG_0x023", "0x023" }, + [36] = { "FLAG_0x024", "0x024" }, + [37] = { "FLAG_0x025", "0x025" }, + [38] = { "FLAG_0x026", "0x026" }, + [39] = { "FLAG_0x027", "0x027" }, + [40] = { "FLAG_HIDE_BULBASAUR_BALL", "HIDE_BULBASAUR_BALL" }, + [41] = { "FLAG_HIDE_SQUIRTLE_BALL", "HIDE_SQUIRTLE_BALL" }, + [42] = { "FLAG_HIDE_CHARMANDER_BALL", "HIDE_CHARMANDER_BALL" }, + [43] = { "FLAG_HIDE_OAK_IN_HIS_LAB", "HIDE_OAK_IN_HIS_LAB" }, + [44] = { "FLAG_HIDE_OAK_IN_PALLET_TOWN", "HIDE_OAK_IN_PALLET_TOWN" }, + [45] = { "FLAG_HIDE_RIVAL_IN_LAB", "HIDE_RIVAL_IN_LAB" }, + [46] = { "FLAG_HIDE_PEWTER_CITY_GYM_GUIDE", "HIDE_PEWTER_CITY_GYM_GUIDE" }, + [47] = { "FLAG_HIDE_DOME_FOSSIL", "HIDE_DOME_FOSSIL" }, + [48] = { "FLAG_HIDE_HELIX_FOSSIL", "HIDE_HELIX_FOSSIL" }, + [49] = { "FLAG_HIDE_NUGGET_BRIDGE_ROCKET", "HIDE_NUGGET_BRIDGE_ROCKET" }, + [50] = { "FLAG_HIDE_BILL_CLEFAIRY", "HIDE_BILL_CLEFAIRY" }, + [51] = { "FLAG_HIDE_BILL_HUMAN_SEA_COTTAGE", "HIDE_BILL_HUMAN_SEA_COTTAGE" }, + [52] = { "FLAG_HIDE_TOWER_FUJI", "HIDE_TOWER_FUJI" }, + [53] = { "FLAG_HIDE_POKEHOUSE_FUJI", "HIDE_POKEHOUSE_FUJI" }, + [54] = { "FLAG_HIDE_LIFT_KEY", "HIDE_LIFT_KEY" }, + [55] = { "FLAG_HIDE_SILPH_SCOPE", "HIDE_SILPH_SCOPE" }, + [56] = { "FLAG_HIDE_HIDEOUT_GIOVANNI", "HIDE_HIDEOUT_GIOVANNI" }, + [57] = { "FLAG_HIDE_TOWN_MAP", "HIDE_TOWN_MAP" }, + [58] = { "FLAG_HIDE_POKEDEX", "HIDE_POKEDEX" }, + [59] = { "FLAG_HIDE_CERULEAN_ROCKET", "HIDE_CERULEAN_ROCKET" }, + [60] = { "FLAG_HIDE_CERULEAN_RIVAL", "HIDE_CERULEAN_RIVAL" }, + [61] = { "FLAG_HIDE_SS_ANNE_RIVAL", "HIDE_SS_ANNE_RIVAL" }, + [62] = { "FLAG_HIDE_SAFFRON_ROCKETS", "HIDE_SAFFRON_ROCKETS" }, + [63] = { "FLAG_HIDE_SAFFRON_CIVILIANS", "HIDE_SAFFRON_CIVILIANS" }, + [64] = { "FLAG_HIDE_SEAFOAM_1F_BOULDER_1", "HIDE_SEAFOAM_1F_BOULDER_1" }, + [65] = { "FLAG_HIDE_SEAFOAM_1F_BOULDER_2", "HIDE_SEAFOAM_1F_BOULDER_2" }, + [66] = { "FLAG_HIDE_SEAFOAM_B1F_BOULDER_1", "HIDE_SEAFOAM_B1F_BOULDER_1" }, + [67] = { "FLAG_HIDE_SEAFOAM_B1F_BOULDER_2", "HIDE_SEAFOAM_B1F_BOULDER_2" }, + [68] = { "FLAG_HIDE_SEAFOAM_B2F_BOULDER_1", "HIDE_SEAFOAM_B2F_BOULDER_1" }, + [69] = { "FLAG_HIDE_SEAFOAM_B2F_BOULDER_2", "HIDE_SEAFOAM_B2F_BOULDER_2" }, + [70] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_1", "HIDE_SEAFOAM_B3F_BOULDER_1" }, + [71] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_2", "HIDE_SEAFOAM_B3F_BOULDER_2" }, + [72] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_3", "HIDE_SEAFOAM_B3F_BOULDER_3" }, + [73] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_4", "HIDE_SEAFOAM_B3F_BOULDER_4" }, + [74] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_5", "HIDE_SEAFOAM_B3F_BOULDER_5" }, + [75] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_6", "HIDE_SEAFOAM_B3F_BOULDER_6" }, + [76] = { "FLAG_HIDE_SEAFOAM_B4F_BOULDER_1", "HIDE_SEAFOAM_B4F_BOULDER_1" }, + [77] = { "FLAG_HIDE_SEAFOAM_B4F_BOULDER_2", "HIDE_SEAFOAM_B4F_BOULDER_2" }, + [78] = { "FLAG_HIDE_SILPH_RIVAL", "HIDE_SILPH_RIVAL" }, + [79] = { "FLAG_HIDE_ROUTE_22_RIVAL", "HIDE_ROUTE_22_RIVAL" }, + [80] = { "FLAG_HIDE_PEWTER_MUSEUM_GUIDE", "HIDE_PEWTER_MUSEUM_GUIDE" }, + [81] = { "FLAG_HIDE_TOWER_RIVAL", "HIDE_TOWER_RIVAL" }, + [82] = { "FLAG_HIDE_MOLTRES", "HIDE_MOLTRES" }, + [83] = { "FLAG_HIDE_SILPH_ROCKETS", "HIDE_SILPH_ROCKETS" }, + [84] = { "FLAG_HIDE_ROUTE_12_SNORLAX", "HIDE_ROUTE_12_SNORLAX" }, + [85] = { "FLAG_HIDE_VIRIDIAN_GIOVANNI", "HIDE_VIRIDIAN_GIOVANNI" }, + [86] = { "FLAG_HIDE_OLD_AMBER", "HIDE_OLD_AMBER" }, + [87] = { "FLAG_HIDE_EEVEE_BALL", "HIDE_EEVEE_BALL" }, + [88] = { "FLAG_HIDE_VICTORY_ROAD_2F_BOULDER", "HIDE_VICTORY_ROAD_2F_BOULDER" }, + [89] = { "FLAG_HIDE_VICTORY_ROAD_3F_BOULDER", "HIDE_VICTORY_ROAD_3F_BOULDER" }, + [90] = { "FLAG_HIDE_OAK_IN_CHAMP_ROOM", "HIDE_OAK_IN_CHAMP_ROOM" }, + [91] = { "FLAG_HIDE_GAME_CORNER_ROCKET", "HIDE_GAME_CORNER_ROCKET" }, + [92] = { "FLAG_HIDE_CERULEAN_CAVE_GUARD", "HIDE_CERULEAN_CAVE_GUARD" }, + [93] = { "FLAG_HIDE_ZAPDOS", "HIDE_ZAPDOS" }, + [94] = { "FLAG_HIDE_TOWER_ROCKET_1", "HIDE_TOWER_ROCKET_1" }, + [95] = { "FLAG_HIDE_CELADON_ROCKETS", "HIDE_CELADON_ROCKETS" }, + [96] = { "FLAG_HIDE_DOJO_HITMONLEE_BALL", "HIDE_DOJO_HITMONLEE_BALL" }, + [97] = { "FLAG_HIDE_DOJO_HITMONCHAN_BALL", "HIDE_DOJO_HITMONCHAN_BALL" }, + [98] = { "FLAG_HIDE_CINNABAR_BILL", "HIDE_CINNABAR_BILL" }, + [99] = { "FLAG_HIDE_UNION_ROOM_PLAYER_1", "HIDE_UNION_ROOM_PLAYER_1" }, + [100] = { "FLAG_HIDE_UNION_ROOM_PLAYER_2", "HIDE_UNION_ROOM_PLAYER_2" }, + [101] = { "FLAG_HIDE_UNION_ROOM_PLAYER_3", "HIDE_UNION_ROOM_PLAYER_3" }, + [102] = { "FLAG_HIDE_UNION_ROOM_PLAYER_4", "HIDE_UNION_ROOM_PLAYER_4" }, + [103] = { "FLAG_HIDE_UNION_ROOM_PLAYER_5", "HIDE_UNION_ROOM_PLAYER_5" }, + [104] = { "FLAG_HIDE_UNION_ROOM_PLAYER_6", "HIDE_UNION_ROOM_PLAYER_6" }, + [105] = { "FLAG_HIDE_UNION_ROOM_PLAYER_7", "HIDE_UNION_ROOM_PLAYER_7" }, + [106] = { "FLAG_HIDE_UNION_ROOM_PLAYER_8", "HIDE_UNION_ROOM_PLAYER_8" }, + [107] = { "FLAG_HIDE_CINNABAR_SEAGALLOP", "HIDE_CINNABAR_SEAGALLOP" }, + [108] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_BLACK_BELT", "HIDE_SAFFRON_FAN_CLUB_BLACK_BELT" }, + [109] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_ROCKER", "HIDE_SAFFRON_FAN_CLUB_ROCKER" }, + [110] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_WOMAN", "HIDE_SAFFRON_FAN_CLUB_WOMAN" }, + [111] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_BEAUTY", "HIDE_SAFFRON_FAN_CLUB_BEAUTY" }, + [112] = { "FLAG_HIDE_MG_DELIVERYMEN", "HIDE_MG_DELIVERYMEN" }, + [113] = { "FLAG_HIDE_ONE_ISLAND_BILL", "HIDE_ONE_ISLAND_BILL" }, + [114] = { "FLAG_HIDE_ONE_ISLAND_POKECENTER_BILL", "HIDE_ONE_ISLAND_POKECENTER_BILL" }, + [115] = { "FLAG_HIDE_ONE_ISLAND_POKECENTER_CELIO", "HIDE_ONE_ISLAND_POKECENTER_CELIO" }, + [116] = { "FLAG_HIDE_TWO_ISLAND_GAME_CORNER_BIKER", "HIDE_TWO_ISLAND_GAME_CORNER_BIKER" }, + [117] = { "FLAG_HIDE_TWO_ISLAND_GAME_CORNER_LOSTELLE", "HIDE_TWO_ISLAND_GAME_CORNER_LOSTELLE" }, + [118] = { "FLAG_HIDE_LOSTELLE_IN_HER_HOME", "HIDE_LOSTELLE_IN_HER_HOME" }, + [119] = { "FLAG_0x077", "0x077" }, + [120] = { "FLAG_0x078", "0x078" }, + [121] = { "FLAG_HIDE_THREE_ISLAND_BIKERS", "HIDE_THREE_ISLAND_BIKERS" }, + [122] = { "FLAG_HIDE_LOSTELLE_IN_BERRY_FOREST", "HIDE_LOSTELLE_IN_BERRY_FOREST" }, + [123] = { "FLAG_HIDE_TWO_ISLAND_WOMAN", "HIDE_TWO_ISLAND_WOMAN" }, + [124] = { "FLAG_HIDE_TWO_ISLAND_BEAUTY", "HIDE_TWO_ISLAND_BEAUTY" }, + [125] = { "FLAG_HIDE_TWO_ISLAND_POKE_MANIAC", "HIDE_TWO_ISLAND_POKE_MANIAC" }, + [126] = { "FLAG_HIDE_THREE_ISLAND_ANTIBIKERS", "HIDE_THREE_ISLAND_ANTIBIKERS" }, + [127] = { "FLAG_0x07F", "0x07F" }, + [128] = { "FLAG_HIDE_ROUTE_16_SNORLAX", "HIDE_ROUTE_16_SNORLAX" }, + [129] = { "FLAG_HIDE_MEWTWO", "HIDE_MEWTWO" }, + [130] = { "FLAG_HIDE_ARTICUNO", "HIDE_ARTICUNO" }, + [131] = { "FLAG_HIDE_TOWER_ROCKET_2", "HIDE_TOWER_ROCKET_2" }, + [132] = { "FLAG_HIDE_TOWER_ROCKET_3", "HIDE_TOWER_ROCKET_3" }, + [133] = { "FLAG_HIDE_POWER_PLANT_ELECTRODE_1", "HIDE_POWER_PLANT_ELECTRODE_1" }, + [134] = { "FLAG_HIDE_POWER_PLANT_ELECTRODE_2", "HIDE_POWER_PLANT_ELECTRODE_2" }, + [135] = { "FLAG_HIDE_SS_ANNE", "HIDE_SS_ANNE" }, + [136] = { "FLAG_HIDE_FIVE_ISLAND_ROCKETS", "HIDE_FIVE_ISLAND_ROCKETS" }, + [137] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_ROCKETS", "HIDE_MT_EMBER_EXTERIOR_ROCKETS" }, + [138] = { "FLAG_HIDE_RUBY", "HIDE_RUBY" }, + [139] = { "FLAG_HIDE_ICEFALL_CAVE_LORELEI", "HIDE_ICEFALL_CAVE_LORELEI" }, + [140] = { "FLAG_HIDE_LORELEI_IN_HER_HOUSE", "HIDE_LORELEI_IN_HER_HOUSE" }, + [141] = { "FLAG_HIDE_ICEFALL_CAVE_ROCKETS", "HIDE_ICEFALL_CAVE_ROCKETS" }, + [142] = { "FLAG_HIDE_RUIN_VALLEY_SCIENTIST", "HIDE_RUIN_VALLEY_SCIENTIST" }, + [143] = { "FLAG_HIDE_SAPPHIRE", "HIDE_SAPPHIRE" }, + [144] = { "FLAG_HIDE_DOTTED_HOLE_SCIENTIST", "HIDE_DOTTED_HOLE_SCIENTIST" }, + [145] = { "FLAG_HIDE_THREE_ISLAND_LONE_BIKER", "HIDE_THREE_ISLAND_LONE_BIKER" }, + [146] = { "FLAG_HIDE_PEWTER_CITY_RUNNING_SHOES_GUY", "HIDE_PEWTER_CITY_RUNNING_SHOES_GUY" }, + [147] = { "FLAG_HIDE_LOST_CAVE_SELPHY", "HIDE_LOST_CAVE_SELPHY" }, + [148] = { "FLAG_HIDE_RESORT_GORGEOUS_SELPHY", "HIDE_RESORT_GORGEOUS_SELPHY" }, + [149] = { "FLAG_HIDE_RESORT_GORGEOUS_INSIDE_SELPHY", "HIDE_RESORT_GORGEOUS_INSIDE_SELPHY" }, + [150] = { "FLAG_HIDE_SELPHYS_BUTLER", "HIDE_SELPHYS_BUTLER" }, + [151] = { "FLAG_HIDE_FOUR_ISLAND_RIVAL", "HIDE_FOUR_ISLAND_RIVAL" }, + [152] = { "FLAG_HIDE_SIX_ISLAND_POKECENTER_RIVAL", "HIDE_SIX_ISLAND_POKECENTER_RIVAL" }, + [153] = { "FLAG_HIDE_DEOXYS", "HIDE_DEOXYS" }, + [154] = { "FLAG_HIDE_BIRTH_ISLAND_METEORITE", "HIDE_BIRTH_ISLAND_METEORITE" }, + [155] = { "FLAG_HIDE_LUGIA", "HIDE_LUGIA" }, + [156] = { "FLAG_HIDE_HO_OH", "HIDE_HO_OH" }, + [157] = { "FLAG_HIDE_POSTGAME_GOSSIPERS", "HIDE_POSTGAME_GOSSIPERS" }, + [158] = { "FLAG_HIDE_FAME_CHECKER_ERIKA_JOURNALS", "HIDE_FAME_CHECKER_ERIKA_JOURNALS" }, + [159] = { "FLAG_HIDE_FAME_CHECKER_KOGA_JOURNAL", "HIDE_FAME_CHECKER_KOGA_JOURNAL" }, + [160] = { "FLAG_HIDE_FAME_CHECKER_LT_SURGE_JOURNAL", "HIDE_FAME_CHECKER_LT_SURGE_JOURNAL" }, + [161] = { "FLAG_HIDE_VERMILION_CITY_OAKS_AIDE", "HIDE_VERMILION_CITY_OAKS_AIDE" }, + [162] = { "FLAG_HIDE_CINNABAR_POKECENTER_BILL", "HIDE_CINNABAR_POKECENTER_BILL" }, + [163] = { "FLAG_HIDE_CREDITS_RIVAL", "HIDE_CREDITS_RIVAL" }, + [164] = { "FLAG_HIDE_CREDITS_OAK", "HIDE_CREDITS_OAK" }, + [165] = { "FLAG_HIDE_LORELEI_HOUSE_MEOWTH_DOLL", "HIDE_LORELEI_HOUSE_MEOWTH_DOLL" }, + [166] = { "FLAG_HIDE_LORELEI_HOUSE_CHANSEY_DOLL", "HIDE_LORELEI_HOUSE_CHANSEY_DOLL" }, + [167] = { "FLAG_HIDE_LORELEIS_HOUSE_NIDORAN_F_DOLL", "HIDE_LORELEIS_HOUSE_NIDORAN_F_DOLL" }, + [168] = { "FLAG_HIDE_LORELEI_HOUSE_JIGGLYPUFF_DOLL", "HIDE_LORELEI_HOUSE_JIGGLYPUFF_DOLL" }, + [169] = { "FLAG_HIDE_LORELEIS_HOUSE_NIDORAN_M_DOLL", "HIDE_LORELEIS_HOUSE_NIDORAN_M_DOLL" }, + [170] = { "FLAG_HIDE_LORELEIS_HOUSE_FEAROW_DOLL", "HIDE_LORELEIS_HOUSE_FEAROW_DOLL" }, + [171] = { "FLAG_HIDE_LORELEIS_HOUSE_PIDGEOT_DOLL", "HIDE_LORELEIS_HOUSE_PIDGEOT_DOLL" }, + [172] = { "FLAG_HIDE_LORELEIS_HOUSE_LAPRAS_DOLL", "HIDE_LORELEIS_HOUSE_LAPRAS_DOLL" }, + [173] = { "FLAG_HIDE_MISC_KANTO_ROCKETS", "HIDE_MISC_KANTO_ROCKETS" }, + [174] = { "FLAG_HIDE_SAFFRON_CITY_POKECENTER_SABRINA_JOURNALS", "HIDE_SAFFRON_CITY_POKECENTER_SABRINA_JOURNALS" }, + [175] = { "FLAG_0x0AF", "0x0AF" }, + [176] = { "FLAG_0x0B0", "0x0B0" }, + [177] = { "FLAG_0x0B1", "0x0B1" }, + [178] = { "FLAG_0x0B2", "0x0B2" }, + [179] = { "FLAG_0x0B3", "0x0B3" }, + [180] = { "FLAG_0x0B4", "0x0B4" }, + [181] = { "FLAG_0x0B5", "0x0B5" }, + [182] = { "FLAG_0x0B6", "0x0B6" }, + [183] = { "FLAG_0x0B7", "0x0B7" }, + [184] = { "FLAG_0x0B8", "0x0B8" }, + [185] = { "FLAG_0x0B9", "0x0B9" }, + [186] = { "FLAG_0x0BA", "0x0BA" }, + [187] = { "FLAG_0x0BB", "0x0BB" }, + [188] = { "FLAG_0x0BC", "0x0BC" }, + [189] = { "FLAG_0x0BD", "0x0BD" }, + [190] = { "FLAG_0x0BE", "0x0BE" }, + [191] = { "FLAG_0x0BF", "0x0BF" }, + [192] = { "FLAG_0x0C0", "0x0C0" }, + [193] = { "FLAG_0x0C1", "0x0C1" }, + [194] = { "FLAG_0x0C2", "0x0C2" }, + [195] = { "FLAG_0x0C3", "0x0C3" }, + [196] = { "FLAG_0x0C4", "0x0C4" }, + [197] = { "FLAG_0x0C5", "0x0C5" }, + [198] = { "FLAG_0x0C6", "0x0C6" }, + [199] = { "FLAG_0x0C7", "0x0C7" }, + [200] = { "FLAG_0x0C8", "0x0C8" }, + [201] = { "FLAG_0x0C9", "0x0C9" }, + [202] = { "FLAG_0x0CA", "0x0CA" }, + [203] = { "FLAG_0x0CB", "0x0CB" }, + [204] = { "FLAG_0x0CC", "0x0CC" }, + [205] = { "FLAG_0x0CD", "0x0CD" }, + [206] = { "FLAG_0x0CE", "0x0CE" }, + [207] = { "FLAG_0x0CF", "0x0CF" }, + [208] = { "FLAG_0x0D0", "0x0D0" }, + [209] = { "FLAG_0x0D1", "0x0D1" }, + [210] = { "FLAG_0x0D2", "0x0D2" }, + [211] = { "FLAG_0x0D3", "0x0D3" }, + [212] = { "FLAG_0x0D4", "0x0D4" }, + [213] = { "FLAG_0x0D5", "0x0D5" }, + [214] = { "FLAG_0x0D6", "0x0D6" }, + [215] = { "FLAG_0x0D7", "0x0D7" }, + [216] = { "FLAG_0x0D8", "0x0D8" }, + [217] = { "FLAG_0x0D9", "0x0D9" }, + [218] = { "FLAG_0x0DA", "0x0DA" }, + [219] = { "FLAG_0x0DB", "0x0DB" }, + [220] = { "FLAG_0x0DC", "0x0DC" }, + [221] = { "FLAG_0x0DD", "0x0DD" }, + [222] = { "FLAG_0x0DE", "0x0DE" }, + [223] = { "FLAG_0x0DF", "0x0DF" }, + [224] = { "FLAG_0x0E0", "0x0E0" }, + [225] = { "FLAG_0x0E1", "0x0E1" }, + [226] = { "FLAG_0x0E2", "0x0E2" }, + [227] = { "FLAG_0x0E3", "0x0E3" }, + [228] = { "FLAG_0x0E4", "0x0E4" }, + [229] = { "FLAG_0x0E5", "0x0E5" }, + [230] = { "FLAG_0x0E6", "0x0E6" }, + [231] = { "FLAG_0x0E7", "0x0E7" }, + [232] = { "FLAG_0x0E8", "0x0E8" }, + [233] = { "FLAG_0x0E9", "0x0E9" }, + [234] = { "FLAG_0x0EA", "0x0EA" }, + [235] = { "FLAG_0x0EB", "0x0EB" }, + [236] = { "FLAG_0x0EC", "0x0EC" }, + [237] = { "FLAG_0x0ED", "0x0ED" }, + [238] = { "FLAG_0x0EE", "0x0EE" }, + [239] = { "FLAG_0x0EF", "0x0EF" }, + [240] = { "FLAG_0x0F0", "0x0F0" }, + [241] = { "FLAG_0x0F1", "0x0F1" }, + [242] = { "FLAG_0x0F2", "0x0F2" }, + [243] = { "FLAG_0x0F3", "0x0F3" }, + [244] = { "FLAG_0x0F4", "0x0F4" }, + [245] = { "FLAG_0x0F5", "0x0F5" }, + [246] = { "FLAG_0x0F6", "0x0F6" }, + [247] = { "FLAG_0x0F7", "0x0F7" }, + [248] = { "FLAG_0x0F8", "0x0F8" }, + [249] = { "FLAG_0x0F9", "0x0F9" }, + [250] = { "FLAG_0x0FA", "0x0FA" }, + [251] = { "FLAG_0x0FB", "0x0FB" }, + [252] = { "FLAG_0x0FC", "0x0FC" }, + [253] = { "FLAG_0x0FD", "0x0FD" }, + [254] = { "FLAG_0x0FE", "0x0FE" }, + [255] = { "FLAG_0x0FF", "0x0FF" }, + [256] = { "FLAG_0x100", "0x100", "VARS_COUNT" }, + [257] = { "FLAG_0x101", "0x101" }, + [258] = { "FLAG_0x102", "0x102" }, + [259] = { "FLAG_0x103", "0x103" }, + [260] = { "FLAG_0x104", "0x104" }, + [261] = { "FLAG_0x105", "0x105" }, + [262] = { "FLAG_0x106", "0x106" }, + [263] = { "FLAG_0x107", "0x107" }, + [264] = { "FLAG_0x108", "0x108" }, + [265] = { "FLAG_0x109", "0x109" }, + [266] = { "FLAG_0x10A", "0x10A" }, + [267] = { "FLAG_0x10B", "0x10B" }, + [268] = { "FLAG_0x10C", "0x10C" }, + [269] = { "FLAG_0x10D", "0x10D" }, + [270] = { "FLAG_0x10E", "0x10E" }, + [271] = { "FLAG_0x10F", "0x10F" }, + [272] = { "FLAG_0x110", "0x110" }, + [273] = { "FLAG_0x111", "0x111" }, + [274] = { "FLAG_0x112", "0x112" }, + [275] = { "FLAG_0x113", "0x113" }, + [276] = { "FLAG_0x114", "0x114" }, + [277] = { "FLAG_0x115", "0x115" }, + [278] = { "FLAG_0x116", "0x116" }, + [279] = { "FLAG_0x117", "0x117" }, + [280] = { "FLAG_0x118", "0x118" }, + [281] = { "FLAG_0x119", "0x119" }, + [282] = { "FLAG_0x11A", "0x11A" }, + [283] = { "FLAG_0x11B", "0x11B" }, + [284] = { "FLAG_0x11C", "0x11C" }, + [285] = { "FLAG_0x11D", "0x11D" }, + [286] = { "FLAG_0x11E", "0x11E" }, + [287] = { "FLAG_0x11F", "0x11F" }, + [288] = { "FLAG_0x120", "0x120" }, + [289] = { "FLAG_0x121", "0x121" }, + [290] = { "FLAG_0x122", "0x122" }, + [291] = { "FLAG_0x123", "0x123" }, + [292] = { "FLAG_0x124", "0x124" }, + [293] = { "FLAG_0x125", "0x125" }, + [294] = { "FLAG_0x126", "0x126" }, + [295] = { "FLAG_0x127", "0x127" }, + [296] = { "FLAG_0x128", "0x128" }, + [297] = { "FLAG_0x129", "0x129" }, + [298] = { "FLAG_0x12A", "0x12A" }, + [299] = { "FLAG_0x12B", "0x12B" }, + [300] = { "FLAG_0x12C", "0x12C" }, + [301] = { "FLAG_0x12D", "0x12D" }, + [302] = { "FLAG_0x12E", "0x12E" }, + [303] = { "FLAG_0x12F", "0x12F" }, + [304] = { "FLAG_0x130", "0x130" }, + [305] = { "FLAG_0x131", "0x131" }, + [306] = { "FLAG_0x132", "0x132" }, + [307] = { "FLAG_0x133", "0x133" }, + [308] = { "FLAG_0x134", "0x134" }, + [309] = { "FLAG_0x135", "0x135" }, + [310] = { "FLAG_0x136", "0x136" }, + [311] = { "FLAG_0x137", "0x137" }, + [312] = { "FLAG_0x138", "0x138" }, + [313] = { "FLAG_0x139", "0x139" }, + [314] = { "FLAG_0x13A", "0x13A" }, + [315] = { "FLAG_0x13B", "0x13B" }, + [316] = { "FLAG_0x13C", "0x13C" }, + [317] = { "FLAG_0x13D", "0x13D" }, + [318] = { "FLAG_0x13E", "0x13E" }, + [319] = { "FLAG_0x13F", "0x13F" }, + [320] = { "FLAG_0x140", "0x140" }, + [321] = { "FLAG_0x141", "0x141" }, + [322] = { "FLAG_0x142", "0x142" }, + [323] = { "FLAG_0x143", "0x143" }, + [324] = { "FLAG_0x144", "0x144" }, + [325] = { "FLAG_0x145", "0x145" }, + [326] = { "FLAG_0x146", "0x146" }, + [327] = { "FLAG_0x147", "0x147" }, + [328] = { "FLAG_0x148", "0x148" }, + [329] = { "FLAG_0x149", "0x149" }, + [330] = { "FLAG_0x14A", "0x14A" }, + [331] = { "FLAG_0x14B", "0x14B" }, + [332] = { "FLAG_0x14C", "0x14C" }, + [333] = { "FLAG_0x14D", "0x14D" }, + [334] = { "FLAG_0x14E", "0x14E" }, + [335] = { "FLAG_0x14F", "0x14F" }, + [336] = { "FLAG_0x150", "0x150" }, + [337] = { "FLAG_0x151", "0x151" }, + [338] = { "FLAG_0x152", "0x152" }, + [339] = { "FLAG_0x153", "0x153" }, + [340] = { "FLAG_HIDE_ROUTE2_ETHER", "HIDE_ROUTE2_ETHER" }, + [341] = { "FLAG_HIDE_ROUTE2_PARALYZE_HEAL", "HIDE_ROUTE2_PARALYZE_HEAL" }, + [342] = { "FLAG_HIDE_VIRIDIAN_FOREST_POKE_BALL", "HIDE_VIRIDIAN_FOREST_POKE_BALL" }, + [343] = { "FLAG_HIDE_VIRIDIAN_FOREST_ANTIDOTE", "HIDE_VIRIDIAN_FOREST_ANTIDOTE" }, + [344] = { "FLAG_HIDE_VIRIDIAN_FOREST_POTION", "HIDE_VIRIDIAN_FOREST_POTION" }, + [345] = { "FLAG_HIDE_MT_MOON_1F_PARALYZE_HEAL", "HIDE_MT_MOON_1F_PARALYZE_HEAL" }, + [346] = { "FLAG_HIDE_MT_MOON_1F_TM09", "HIDE_MT_MOON_1F_TM09" }, + [347] = { "FLAG_HIDE_MT_MOON_1F_POTION", "HIDE_MT_MOON_1F_POTION" }, + [348] = { "FLAG_HIDE_MT_MOON_1F_RARE_CANDY", "HIDE_MT_MOON_1F_RARE_CANDY" }, + [349] = { "FLAG_HIDE_MT_MOON_1F_ESCAPE_ROPE", "HIDE_MT_MOON_1F_ESCAPE_ROPE" }, + [350] = { "FLAG_HIDE_MT_MOON_1F_MOON_STONE", "HIDE_MT_MOON_1F_MOON_STONE" }, + [351] = { "FLAG_HIDE_MT_MOON_B2F_STAR_PIECE", "HIDE_MT_MOON_B2F_STAR_PIECE" }, + [352] = { "FLAG_HIDE_MT_MOON_B2F_TM46", "HIDE_MT_MOON_B2F_TM46" }, + [353] = { "FLAG_HIDE_ROUTE4_TM05", "HIDE_ROUTE4_TM05" }, + [354] = { "FLAG_HIDE_ROUTE24_TM45", "HIDE_ROUTE24_TM45" }, + [355] = { "FLAG_HIDE_ROUTE25_TM43", "HIDE_ROUTE25_TM43" }, + [356] = { "FLAG_HIDE_SSANNE_1F_ROOM2_TM31", "HIDE_SSANNE_1F_ROOM2_TM31" }, + [357] = { "FLAG_HIDE_SSANNE_2F_ROOM2_STARDUST", "HIDE_SSANNE_2F_ROOM2_STARDUST" }, + [358] = { "FLAG_HIDE_SSANNE_2F_ROOM4_X_ATTACK", "HIDE_SSANNE_2F_ROOM4_X_ATTACK" }, + [359] = { "FLAG_HIDE_SSANNE_B1F_ROOM2_TM44", "HIDE_SSANNE_B1F_ROOM2_TM44" }, + [360] = { "FLAG_HIDE_SSANNE_B1F_ROOM3_ETHER", "HIDE_SSANNE_B1F_ROOM3_ETHER" }, + [361] = { "FLAG_HIDE_SSANNE_B1F_ROOM5_SUPER_POTION", "HIDE_SSANNE_B1F_ROOM5_SUPER_POTION" }, + [362] = { "FLAG_HIDE_SSANNE_KITCHEN_GREAT_BALL", "HIDE_SSANNE_KITCHEN_GREAT_BALL" }, + [363] = { "FLAG_HIDE_ROUTE9_TM40", "HIDE_ROUTE9_TM40" }, + [364] = { "FLAG_HIDE_ROCKET_HIDEOUT_B1F_ESCAPE_ROPE", "HIDE_ROCKET_HIDEOUT_B1F_ESCAPE_ROPE" }, + [365] = { "FLAG_HIDE_ROCKET_HIDEOUT_B1F_HYPER_POTION", "HIDE_ROCKET_HIDEOUT_B1F_HYPER_POTION" }, + [366] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_X_SPEED", "HIDE_ROCKET_HIDEOUT_B2F_X_SPEED" }, + [367] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_MOON_STONE", "HIDE_ROCKET_HIDEOUT_B2F_MOON_STONE" }, + [368] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_TM12", "HIDE_ROCKET_HIDEOUT_B2F_TM12" }, + [369] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_SUPER_POTION", "HIDE_ROCKET_HIDEOUT_B2F_SUPER_POTION" }, + [370] = { "FLAG_HIDE_ROCKET_HIDEOUT_B3F_RARE_CANDY", "HIDE_ROCKET_HIDEOUT_B3F_RARE_CANDY" }, + [371] = { "FLAG_HIDE_ROCKET_HIDEOUT_B3F_TM21", "HIDE_ROCKET_HIDEOUT_B3F_TM21" }, + [372] = { "FLAG_HIDE_ROCKET_HIDEOUT_B4F_TM49", "HIDE_ROCKET_HIDEOUT_B4F_TM49" }, + [373] = { "FLAG_HIDE_ROCKET_HIDEOUT_B4F_MAX_ETHER", "HIDE_ROCKET_HIDEOUT_B4F_MAX_ETHER" }, + [374] = { "FLAG_HIDE_ROCKET_HIDEOUT_B4F_CALCIUM", "HIDE_ROCKET_HIDEOUT_B4F_CALCIUM" }, + [375] = { "FLAG_HIDE_POKEMON_TOWER_3F_ESCAPE_ROPE", "HIDE_POKEMON_TOWER_3F_ESCAPE_ROPE" }, + [376] = { "FLAG_HIDE_POKEMON_TOWER_4F_ELIXIR", "HIDE_POKEMON_TOWER_4F_ELIXIR" }, + [377] = { "FLAG_HIDE_POKEMON_TOWER_4F_AWAKENING", "HIDE_POKEMON_TOWER_4F_AWAKENING" }, + [378] = { "FLAG_HIDE_POKEMON_TOWER_4F_GREAT_BALL", "HIDE_POKEMON_TOWER_4F_GREAT_BALL" }, + [379] = { "FLAG_HIDE_POKEMON_TOWER_5F_NUGGET", "HIDE_POKEMON_TOWER_5F_NUGGET" }, + [380] = { "FLAG_HIDE_POKEMON_TOWER_6F_RARE_CANDY", "HIDE_POKEMON_TOWER_6F_RARE_CANDY" }, + [381] = { "FLAG_HIDE_POKEMON_TOWER_6F_X_ACCURACY", "HIDE_POKEMON_TOWER_6F_X_ACCURACY" }, + [382] = { "FLAG_HIDE_ROUTE12_TM48", "HIDE_ROUTE12_TM48" }, + [383] = { "FLAG_HIDE_ROUTE12_IRON", "HIDE_ROUTE12_IRON" }, + [384] = { "FLAG_HIDE_ROUTE15_TM18", "HIDE_ROUTE15_TM18" }, + [385] = { "FLAG_HIDE_SAFARI_ZONE_CENTER_NUGGET", "HIDE_SAFARI_ZONE_CENTER_NUGGET" }, + [386] = { "FLAG_HIDE_SAFARI_ZONE_EAST_MAX_POTION", "HIDE_SAFARI_ZONE_EAST_MAX_POTION" }, + [387] = { "FLAG_HIDE_SAFARI_ZONE_EAST_FULL_RESTORE", "HIDE_SAFARI_ZONE_EAST_FULL_RESTORE" }, + [388] = { "FLAG_HIDE_SAFARI_ZONE_EAST_TM11", "HIDE_SAFARI_ZONE_EAST_TM11" }, + [389] = { "FLAG_HIDE_SAFARI_ZONE_EAST_LEAF_STONE", "HIDE_SAFARI_ZONE_EAST_LEAF_STONE" }, + [390] = { "FLAG_HIDE_SAFARI_ZONE_NORTH_PROTEIN", "HIDE_SAFARI_ZONE_NORTH_PROTEIN" }, + [391] = { "FLAG_HIDE_SAFARI_ZONE_NORTH_TM47", "HIDE_SAFARI_ZONE_NORTH_TM47" }, + [392] = { "FLAG_HIDE_SAFARI_ZONE_WEST_TM32", "HIDE_SAFARI_ZONE_WEST_TM32" }, + [393] = { "FLAG_HIDE_SAFARI_ZONE_WEST_GOLD_TEETH", "HIDE_SAFARI_ZONE_WEST_GOLD_TEETH" }, + [394] = { "FLAG_HIDE_SAFARI_ZONE_WEST_MAX_POTION", "HIDE_SAFARI_ZONE_WEST_MAX_POTION" }, + [395] = { "FLAG_HIDE_SAFARI_ZONE_WEST_MAX_REVIVE", "HIDE_SAFARI_ZONE_WEST_MAX_REVIVE" }, + [396] = { "FLAG_HIDE_SILPH_CO_3F_HYPER_POTION", "HIDE_SILPH_CO_3F_HYPER_POTION" }, + [397] = { "FLAG_HIDE_SILPH_CO_4F_MAX_REVIVE", "HIDE_SILPH_CO_4F_MAX_REVIVE" }, + [398] = { "FLAG_HIDE_SILPH_CO_4F_ESCAPE_ROPE", "HIDE_SILPH_CO_4F_ESCAPE_ROPE" }, + [399] = { "FLAG_HIDE_SILPH_CO_4F_FULL_HEAL", "HIDE_SILPH_CO_4F_FULL_HEAL" }, + [400] = { "FLAG_HIDE_SILPH_CO_5F_PROTEIN", "HIDE_SILPH_CO_5F_PROTEIN" }, + [401] = { "FLAG_HIDE_SILPH_CO_5F_TM01", "HIDE_SILPH_CO_5F_TM01" }, + [402] = { "FLAG_HIDE_SILPH_CO_5F_CARD_KEY", "HIDE_SILPH_CO_5F_CARD_KEY" }, + [403] = { "FLAG_HIDE_SILPH_CO_6F_HP_UP", "HIDE_SILPH_CO_6F_HP_UP" }, + [404] = { "FLAG_HIDE_SILPH_CO_6F_X_SPECIAL", "HIDE_SILPH_CO_6F_X_SPECIAL" }, + [405] = { "FLAG_HIDE_SILPH_CO_7F_CALCIUM", "HIDE_SILPH_CO_7F_CALCIUM" }, + [406] = { "FLAG_HIDE_SILPH_CO_7F_TM08", "HIDE_SILPH_CO_7F_TM08" }, + [407] = { "FLAG_HIDE_SILPH_CO_10F_CARBOS", "HIDE_SILPH_CO_10F_CARBOS" }, + [408] = { "FLAG_HIDE_SILPH_CO_10F_ULTRA_BALL", "HIDE_SILPH_CO_10F_ULTRA_BALL" }, + [409] = { "FLAG_HIDE_SILPH_CO_10F_RARE_CANDY", "HIDE_SILPH_CO_10F_RARE_CANDY" }, + [410] = { "FLAG_HIDE_POWER_PLANT_MAX_POTION", "HIDE_POWER_PLANT_MAX_POTION" }, + [411] = { "FLAG_HIDE_POWER_PLANT_TM17", "HIDE_POWER_PLANT_TM17" }, + [412] = { "FLAG_HIDE_POWER_PLANT_TM25", "HIDE_POWER_PLANT_TM25" }, + [413] = { "FLAG_HIDE_POWER_PLANT_THUNDER_STONE", "HIDE_POWER_PLANT_THUNDER_STONE" }, + [414] = { "FLAG_HIDE_POWER_PLANT_ELIXIR", "HIDE_POWER_PLANT_ELIXIR" }, + [415] = { "FLAG_HIDE_POKEMON_MANSION_1F_CARBOS", "HIDE_POKEMON_MANSION_1F_CARBOS" }, + [416] = { "FLAG_HIDE_POKEMON_MANSION_1F_ESCAPE_ROPE", "HIDE_POKEMON_MANSION_1F_ESCAPE_ROPE" }, + [417] = { "FLAG_HIDE_POKEMON_MANSION_2F_CALCIUM", "HIDE_POKEMON_MANSION_2F_CALCIUM" }, + [418] = { "FLAG_HIDE_POKEMON_MANSION_3F_MAX_POTION", "HIDE_POKEMON_MANSION_3F_MAX_POTION" }, + [419] = { "FLAG_HIDE_POKEMON_MANSION_3F_IRON", "HIDE_POKEMON_MANSION_3F_IRON" }, + [420] = { "FLAG_HIDE_POKEMON_MANSION_B1F_TM14", "HIDE_POKEMON_MANSION_B1F_TM14" }, + [421] = { "FLAG_HIDE_POKEMON_MANSION_B1F_FULL_RESTORE", "HIDE_POKEMON_MANSION_B1F_FULL_RESTORE" }, + [422] = { "FLAG_0x1A6", "0x1A6" }, + [423] = { "FLAG_HIDE_POKEMON_MANSION_B1F_TM22", "HIDE_POKEMON_MANSION_B1F_TM22" }, + [424] = { "FLAG_HIDE_POKEMON_MANSION_B1F_SECRET_KEY", "HIDE_POKEMON_MANSION_B1F_SECRET_KEY" }, + [425] = { "FLAG_HIDE_VICTORY_ROAD_1F_RARE_CANDY", "HIDE_VICTORY_ROAD_1F_RARE_CANDY" }, + [426] = { "FLAG_HIDE_VICTORY_ROAD_1F_TM02", "HIDE_VICTORY_ROAD_1F_TM02" }, + [427] = { "FLAG_HIDE_VICTORY_ROAD_2F_GUARD_SPEC", "HIDE_VICTORY_ROAD_2F_GUARD_SPEC" }, + [428] = { "FLAG_HIDE_VICTORY_ROAD_2F_TM07", "HIDE_VICTORY_ROAD_2F_TM07" }, + [429] = { "FLAG_HIDE_VICTORY_ROAD_2F_FULL_HEAL", "HIDE_VICTORY_ROAD_2F_FULL_HEAL" }, + [430] = { "FLAG_HIDE_VICTORY_ROAD_2F_TM37", "HIDE_VICTORY_ROAD_2F_TM37" }, + [431] = { "FLAG_HIDE_VICTORY_ROAD_3F_MAX_REVIVE", "HIDE_VICTORY_ROAD_3F_MAX_REVIVE" }, + [432] = { "FLAG_HIDE_VICTORY_ROAD_3F_TM50", "HIDE_VICTORY_ROAD_3F_TM50" }, + [433] = { "FLAG_HIDE_CERULEAN_CAVE_1F_MAX_ELIXIR", "HIDE_CERULEAN_CAVE_1F_MAX_ELIXIR" }, + [434] = { "FLAG_HIDE_CERULEAN_CAVE_1F_NUGGET", "HIDE_CERULEAN_CAVE_1F_NUGGET" }, + [435] = { "FLAG_HIDE_CERULEAN_CAVE_1F_FULL_RESTORE", "HIDE_CERULEAN_CAVE_1F_FULL_RESTORE" }, + [436] = { "FLAG_HIDE_CERULEAN_CAVE_2F_FULL_RESTORE", "HIDE_CERULEAN_CAVE_2F_FULL_RESTORE" }, + [437] = { "FLAG_HIDE_CERULEAN_CAVE_2F_PP_UP", "HIDE_CERULEAN_CAVE_2F_PP_UP" }, + [438] = { "FLAG_HIDE_CERULEAN_CAVE_2F_ULTRA_BALL", "HIDE_CERULEAN_CAVE_2F_ULTRA_BALL" }, + [439] = { "FLAG_HIDE_CERULEAN_CAVE_B1F_MAX_REVIVE", "HIDE_CERULEAN_CAVE_B1F_MAX_REVIVE" }, + [440] = { "FLAG_HIDE_CERULEAN_CAVE_B1F_ULTRA_BALL", "HIDE_CERULEAN_CAVE_B1F_ULTRA_BALL" }, + [441] = { "FLAG_HIDE_FUCHSIA_CITY_WARDENS_HOUSE_RARE_CANDY", "HIDE_FUCHSIA_CITY_WARDENS_HOUSE_RARE_CANDY" }, + [442] = { "FLAG_HIDE_TWO_ISLAND_REVIVE", "HIDE_TWO_ISLAND_REVIVE" }, + [443] = { "FLAG_HIDE_THREE_ISLAND_ZINC", "HIDE_THREE_ISLAND_ZINC" }, + [444] = { "FLAG_0x1BC", "0x1BC" }, + [445] = { "FLAG_0x1BD", "0x1BD" }, + [446] = { "FLAG_HIDE_VIRIDIAN_FOREST_POTION_2", "HIDE_VIRIDIAN_FOREST_POTION_2" }, + [447] = { "FLAG_HIDE_MT_MOON_B2F_REVIVE", "HIDE_MT_MOON_B2F_REVIVE" }, + [448] = { "FLAG_HIDE_MT_MOON_B2F_ANTIDOTE", "HIDE_MT_MOON_B2F_ANTIDOTE" }, + [449] = { "FLAG_HIDE_ROUTE11_X_DEFEND", "HIDE_ROUTE11_X_DEFEND" }, + [450] = { "FLAG_HIDE_ROUTE9_BURN_HEAL", "HIDE_ROUTE9_BURN_HEAL" }, + [451] = { "FLAG_HIDE_ROCK_TUNNEL_1F_REPEL", "HIDE_ROCK_TUNNEL_1F_REPEL" }, + [452] = { "FLAG_HIDE_ROCK_TUNNEL_1F_PEARL", "HIDE_ROCK_TUNNEL_1F_PEARL" }, + [453] = { "FLAG_HIDE_ROCK_TUNNEL_1F_ESCAPE_ROPE", "HIDE_ROCK_TUNNEL_1F_ESCAPE_ROPE" }, + [454] = { "FLAG_HIDE_ROCK_TUNNEL_B1F_REVIVE", "HIDE_ROCK_TUNNEL_B1F_REVIVE" }, + [455] = { "FLAG_HIDE_ROCK_TUNNEL_B1F_MAX_ETHER", "HIDE_ROCK_TUNNEL_B1F_MAX_ETHER" }, + [456] = { "FLAG_HIDE_SILPH_CO_8F_IRON", "HIDE_SILPH_CO_8F_IRON" }, + [457] = { "FLAG_HIDE_SILPH_CO_11F_ZINC", "HIDE_SILPH_CO_11F_ZINC" }, + [458] = { "FLAG_HIDE_POKEMON_MANSION_1F_PROTEIN", "HIDE_POKEMON_MANSION_1F_PROTEIN" }, + [459] = { "FLAG_HIDE_POKEMON_MANSION_2F_ZINC", "HIDE_POKEMON_MANSION_2F_ZINC" }, + [460] = { "FLAG_HIDE_POKEMON_MANSION_2F_HP_UP", "HIDE_POKEMON_MANSION_2F_HP_UP" }, + [461] = { "FLAG_HIDE_VIRIDIAN_CITY_POTION", "HIDE_VIRIDIAN_CITY_POTION" }, + [462] = { "FLAG_HIDE_ROUTE11_GREAT_BALL", "HIDE_ROUTE11_GREAT_BALL" }, + [463] = { "FLAG_HIDE_ROUTE11_AWAKENING", "HIDE_ROUTE11_AWAKENING" }, + [464] = { "FLAG_HIDE_POKEMON_TOWER_5F_CLEANSE_TAG", "HIDE_POKEMON_TOWER_5F_CLEANSE_TAG" }, + [465] = { "FLAG_HIDE_CELADON_CITY_ETHER", "HIDE_CELADON_CITY_ETHER" }, + [466] = { "FLAG_HIDE_ROCKET_HIDEOUT_B3F_BLACK_GLASSES", "HIDE_ROCKET_HIDEOUT_B3F_BLACK_GLASSES" }, + [467] = { "FLAG_HIDE_SAFARI_ZONE_NORTH_QUICK_CLAW", "HIDE_SAFARI_ZONE_NORTH_QUICK_CLAW" }, + [468] = { "FLAG_HIDE_SEAFOAM_ISLANDS_1F_ICE_HEAL", "HIDE_SEAFOAM_ISLANDS_1F_ICE_HEAL" }, + [469] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B1F_WATER_STONE", "HIDE_SEAFOAM_ISLANDS_B1F_WATER_STONE" }, + [470] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B1F_REVIVE", "HIDE_SEAFOAM_ISLANDS_B1F_REVIVE" }, + [471] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B2F_BIG_PEARL", "HIDE_SEAFOAM_ISLANDS_B2F_BIG_PEARL" }, + [472] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B4F_ULTRA_BALL", "HIDE_SEAFOAM_ISLANDS_B4F_ULTRA_BALL" }, + [473] = { "FLAG_HIDE_FOUR_ISLAND_STAR_PIECE", "HIDE_FOUR_ISLAND_STAR_PIECE" }, + [474] = { "FLAG_HIDE_FOUR_ISLAND_STARDUST", "HIDE_FOUR_ISLAND_STARDUST" }, + [475] = { "FLAG_HIDE_ONE_ISLAND_KINDLE_ROAD_ETHER", "HIDE_ONE_ISLAND_KINDLE_ROAD_ETHER" }, + [476] = { "FLAG_HIDE_ONE_ISLAND_KINDLE_ROAD_MAX_REPEL", "HIDE_ONE_ISLAND_KINDLE_ROAD_MAX_REPEL" }, + [477] = { "FLAG_HIDE_ONE_ISLAND_KINDLE_ROAD_CARBOS", "HIDE_ONE_ISLAND_KINDLE_ROAD_CARBOS" }, + [478] = { "FLAG_HIDE_FIVE_ISLAND_MEADOW_MAX_POTION", "HIDE_FIVE_ISLAND_MEADOW_MAX_POTION" }, + [479] = { "FLAG_HIDE_FIVE_ISLAND_MEADOW_PP_UP", "HIDE_FIVE_ISLAND_MEADOW_PP_UP" }, + [480] = { "FLAG_HIDE_FIVE_ISLAND_MEMORIAL_PILLAR_METAL_COAT", "HIDE_FIVE_ISLAND_MEMORIAL_PILLAR_METAL_COAT" }, + [481] = { "FLAG_HIDE_SIX_ISLAND_OUTCAST_ISLAND_PP_UP", "HIDE_SIX_ISLAND_OUTCAST_ISLAND_PP_UP" }, + [482] = { "FLAG_HIDE_SIX_ISLAND_WATER_PATH_ELIXIR", "HIDE_SIX_ISLAND_WATER_PATH_ELIXIR" }, + [483] = { "FLAG_HIDE_SIX_ISLAND_WATER_PATH_DRAGON_SCALE", "HIDE_SIX_ISLAND_WATER_PATH_DRAGON_SCALE" }, + [484] = { "FLAG_HIDE_SIX_ISLAND_RUIN_VALLEY_FULL_RESTORE", "HIDE_SIX_ISLAND_RUIN_VALLEY_FULL_RESTORE" }, + [485] = { "FLAG_HIDE_SIX_ISLAND_RUIN_VALLEY_HP_UP", "HIDE_SIX_ISLAND_RUIN_VALLEY_HP_UP" }, + [486] = { "FLAG_HIDE_SIX_ISLAND_RUIN_VALLEY_SUN_STONE", "HIDE_SIX_ISLAND_RUIN_VALLEY_SUN_STONE" }, + [487] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_KINGS_ROCK", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_KINGS_ROCK" }, + [488] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_MAX_ELIXIR", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_MAX_ELIXIR" }, + [489] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_NUGGET", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_NUGGET" }, + [490] = { "FLAG_HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ETHER", "HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ETHER" }, + [491] = { "FLAG_HIDE_THREE_ISLAND_BERRY_FOREST_FULL_HEAL", "HIDE_THREE_ISLAND_BERRY_FOREST_FULL_HEAL" }, + [492] = { "FLAG_HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ELIXIR", "HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ELIXIR" }, + [493] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_ULTRA_BALL", "HIDE_MT_EMBER_EXTERIOR_ULTRA_BALL" }, + [494] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_FIRE_STONE", "HIDE_MT_EMBER_EXTERIOR_FIRE_STONE" }, + [495] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_DIRE_HIT", "HIDE_MT_EMBER_EXTERIOR_DIRE_HIT" }, + [496] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_ULTRA_BALL", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_ULTRA_BALL" }, + [497] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_HM07", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_HM07" }, + [498] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_FULL_RESTORE", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_FULL_RESTORE" }, + [499] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_NEVER_MELT_ICE", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_NEVER_MELT_ICE" }, + [500] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_BIG_PEARL", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_BIG_PEARL" }, + [501] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_TM36", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_TM36" }, + [502] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_PEARL", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_PEARL" }, + [503] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_UP_GRADE", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_UP_GRADE" }, + [504] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM10_SILK_SCARF", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM10_SILK_SCARF" }, + [505] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM11_LAX_INCENSE", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM11_LAX_INCENSE" }, + [506] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM12_SEA_INCENSE", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM12_SEA_INCENSE" }, + [507] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM13_MAX_REVIVE", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM13_MAX_REVIVE" }, + [508] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM14_RARE_CANDY", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM14_RARE_CANDY" }, + [509] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_HOUSE_LUCKY_PUNCH", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_HOUSE_LUCKY_PUNCH" }, + [510] = { "FLAG_HIDE_SILPH_CO_4F_TM41", "HIDE_SILPH_CO_4F_TM41" }, + [511] = { "FLAG_0x1FF", "0x1FF" }, + [512] = { "FLAG_0x200", "0x200" }, + [513] = { "FLAG_0x201", "0x201" }, + [514] = { "FLAG_0x202", "0x202" }, + [515] = { "FLAG_0x203", "0x203" }, + [516] = { "FLAG_0x204", "0x204" }, + [517] = { "FLAG_0x205", "0x205" }, + [518] = { "FLAG_0x206", "0x206" }, + [519] = { "FLAG_0x207", "0x207" }, + [520] = { "FLAG_0x208", "0x208" }, + [521] = { "FLAG_0x209", "0x209" }, + [522] = { "FLAG_0x20A", "0x20A" }, + [523] = { "FLAG_0x20B", "0x20B" }, + [524] = { "FLAG_0x20C", "0x20C" }, + [525] = { "FLAG_0x20D", "0x20D" }, + [526] = { "FLAG_0x20E", "0x20E" }, + [527] = { "FLAG_0x20F", "0x20F" }, + [528] = { "FLAG_0x210", "0x210" }, + [529] = { "FLAG_0x211", "0x211" }, + [530] = { "FLAG_0x212", "0x212" }, + [531] = { "FLAG_0x213", "0x213" }, + [532] = { "FLAG_0x214", "0x214" }, + [533] = { "FLAG_0x215", "0x215" }, + [534] = { "FLAG_0x216", "0x216" }, + [535] = { "FLAG_0x217", "0x217" }, + [536] = { "FLAG_0x218", "0x218" }, + [537] = { "FLAG_0x219", "0x219" }, + [538] = { "FLAG_0x21A", "0x21A" }, + [539] = { "FLAG_0x21B", "0x21B" }, + [540] = { "FLAG_0x21C", "0x21C" }, + [541] = { "FLAG_0x21D", "0x21D" }, + [542] = { "FLAG_0x21E", "0x21E" }, + [543] = { "FLAG_0x21F", "0x21F" }, + [544] = { "FLAG_0x220", "0x220" }, + [545] = { "FLAG_0x221", "0x221" }, + [546] = { "FLAG_0x222", "0x222" }, + [547] = { "FLAG_0x223", "0x223" }, + [548] = { "FLAG_0x224", "0x224" }, + [549] = { "FLAG_0x225", "0x225" }, + [550] = { "FLAG_0x226", "0x226" }, + [551] = { "FLAG_0x227", "0x227" }, + [552] = { "FLAG_0x228", "0x228" }, + [553] = { "FLAG_0x229", "0x229" }, + [554] = { "FLAG_0x22A", "0x22A" }, + [555] = { "FLAG_0x22B", "0x22B" }, + [556] = { "FLAG_0x22C", "0x22C" }, + [557] = { "FLAG_0x22D", "0x22D" }, + [558] = { "FLAG_0x22E", "0x22E" }, + [559] = { "FLAG_0x22F", "0x22F" }, + [560] = { "FLAG_GOT_POTION_ON_ROUTE_1", "GOT_POTION_ON_ROUTE_1", "STORY_FLAGS_START" }, + [561] = { "FLAG_GOT_TM34_FROM_SURGE", "GOT_TM34_FROM_SURGE" }, + [562] = { "FLAG_GOT_FOSSIL_FROM_MT_MOON", "GOT_FOSSIL_FROM_MT_MOON" }, + [563] = { "FLAG_HELPED_BILL_IN_SEA_COTTAGE", "HELPED_BILL_IN_SEA_COTTAGE" }, + [564] = { "FLAG_GOT_SS_TICKET", "GOT_SS_TICKET" }, + [565] = { "FLAG_GOT_SS_TICKET_DUP", "GOT_SS_TICKET_DUP" }, + [566] = { "FLAG_GOT_TM42_AT_MEMORIAL_PILLAR", "GOT_TM42_AT_MEMORIAL_PILLAR" }, + [567] = { "FLAG_GOT_HM01", "GOT_HM01" }, + [568] = { "FLAG_GOT_HM02", "GOT_HM02" }, + [569] = { "FLAG_GOT_HM03", "GOT_HM03" }, + [570] = { "FLAG_GOT_HM04", "GOT_HM04" }, + [571] = { "FLAG_GOT_HM05", "GOT_HM05" }, + [572] = { "FLAG_RESCUED_MR_FUJI", "RESCUED_MR_FUJI" }, + [573] = { "FLAG_GOT_POKE_FLUTE", "GOT_POKE_FLUTE" }, + [574] = { "FLAG_0x23E", "0x23E" }, + [575] = { "FLAG_GOT_TM28_FROM_ROCKET", "GOT_TM28_FROM_ROCKET" }, + [576] = { "FLAG_GOT_OLD_ROD", "GOT_OLD_ROD" }, + [577] = { "FLAG_GOT_BIKE_VOUCHER", "GOT_BIKE_VOUCHER" }, + [578] = { "FLAG_0x242", "0x242" }, + [579] = { "FLAG_GOT_COIN_CASE", "GOT_COIN_CASE" }, + [580] = { "FLAG_GOT_GOOD_ROD", "GOT_GOOD_ROD" }, + [581] = { "FLAG_GOT_TM29_FROM_MR_PSYCHIC", "GOT_TM29_FROM_MR_PSYCHIC" }, + [582] = { "FLAG_GOT_LAPRAS_FROM_SILPH", "GOT_LAPRAS_FROM_SILPH" }, + [583] = { "FLAG_GOT_POKEBALLS_FROM_OAK_AFTER_22_RIVAL", "GOT_POKEBALLS_FROM_OAK_AFTER_22_RIVAL" }, + [584] = { "FLAG_DID_MIMIEN_TRADE", "DID_MIMIEN_TRADE" }, + [585] = { "FLAG_BOUGHT_MAGIKARP", "BOUGHT_MAGIKARP" }, + [586] = { "FLAG_DID_ZYNX_TRADE", "DID_ZYNX_TRADE" }, + [587] = { "FLAG_DID_MS_NIDO_TRADE", "DID_MS_NIDO_TRADE" }, + [588] = { "FLAG_0x24C", "0x24C" }, + [589] = { "FLAG_DID_CH_DING_TRADE", "DID_CH_DING_TRADE" }, + [590] = { "FLAG_GOT_TM38_FROM_BLAINE", "GOT_TM38_FROM_BLAINE" }, + [591] = { "FLAG_OAK_SKIP_22_RIVAL_CHECK", "OAK_SKIP_22_RIVAL_CHECK" }, + [592] = { "FLAG_GOT_MASTER_BALL_FROM_SILPH", "GOT_MASTER_BALL_FROM_SILPH" }, + [593] = { "FLAG_DID_NINA_TRADE", "DID_NINA_TRADE" }, + [594] = { "FLAG_GOT_ITEMFINDER", "GOT_ITEMFINDER" }, + [595] = { "FLAG_WOKE_UP_ROUTE_12_SNORLAX", "WOKE_UP_ROUTE_12_SNORLAX" }, + [596] = { "FLAG_GOT_TM39_FROM_BROCK", "GOT_TM39_FROM_BROCK" }, + [597] = { "FLAG_GOT_SUPER_ROD", "GOT_SUPER_ROD" }, + [598] = { "FLAG_GOT_EXP_SHARE_FROM_OAKS_AIDE", "GOT_EXP_SHARE_FROM_OAKS_AIDE" }, + [599] = { "FLAG_DID_MARC_TRADE", "DID_MARC_TRADE" }, + [600] = { "FLAG_BEAT_RIVAL_IN_OAKS_LAB", "BEAT_RIVAL_IN_OAKS_LAB" }, + [601] = { "FLAG_GOT_TM06_FROM_KOGA", "GOT_TM06_FROM_KOGA" }, + [602] = { "FLAG_0x25A", "0x25A" }, + [603] = { "FLAG_GOT_TM27", "GOT_TM27" }, + [604] = { "FLAG_0x25C", "0x25C" }, + [605] = { "FLAG_0x25D", "0x25D" }, + [606] = { "FLAG_GOT_OLD_AMBER", "GOT_OLD_AMBER" }, + [607] = { "FLAG_0x25F", "0x25F" }, + [608] = { "FLAG_0x260", "0x260" }, + [609] = { "FLAG_0x261", "0x261" }, + [610] = { "FLAG_0x262", "0x262" }, + [611] = { "FLAG_GOT_EEVEE", "GOT_EEVEE" }, + [612] = { "FLAG_FOUND_BOTH_VERMILION_GYM_SWITCHES", "FOUND_BOTH_VERMILION_GYM_SWITCHES" }, + [613] = { "FLAG_CINNABAR_GYM_QUIZ_1", "CINNABAR_GYM_QUIZ_1" }, + [614] = { "FLAG_PENDING_DAYCARE_EGG", "PENDING_DAYCARE_EGG" }, + [615] = { "FLAG_CINNABAR_GYM_QUIZ_2", "CINNABAR_GYM_QUIZ_2" }, + [616] = { "FLAG_CINNABAR_GYM_QUIZ_3", "CINNABAR_GYM_QUIZ_3" }, + [617] = { "FLAG_CINNABAR_GYM_QUIZ_4", "CINNABAR_GYM_QUIZ_4" }, + [618] = { "FLAG_CINNABAR_GYM_QUIZ_5", "CINNABAR_GYM_QUIZ_5" }, + [619] = { "FLAG_CINNABAR_GYM_QUIZ_6", "CINNABAR_GYM_QUIZ_6" }, + [620] = { "FLAG_POKEMON_MANSION_SWITCH_STATE", "POKEMON_MANSION_SWITCH_STATE" }, + [621] = { "FLAG_OPENED_ROCKET_HIDEOUT", "OPENED_ROCKET_HIDEOUT" }, + [622] = { "FLAG_GOT_10_COINS_FROM_GAMBLER", "GOT_10_COINS_FROM_GAMBLER" }, + [623] = { "FLAG_GOT_20_COINS_FROM_GAMBLER", "GOT_20_COINS_FROM_GAMBLER" }, + [624] = { "FLAG_GOT_20_COINS_FROM_GAMBLER_2", "GOT_20_COINS_FROM_GAMBLER_2" }, + [625] = { "FLAG_GOT_BICYCLE", "GOT_BICYCLE" }, + [626] = { "FLAG_GOT_DOME_FOSSIL", "GOT_DOME_FOSSIL" }, + [627] = { "FLAG_GOT_HELIX_FOSSIL", "GOT_HELIX_FOSSIL" }, + [628] = { "FLAG_DID_ESPHERE_TRADE", "DID_ESPHERE_TRADE" }, + [629] = { "FLAG_DID_TANGENY_TRADE", "DID_TANGENY_TRADE" }, + [630] = { "FLAG_DID_SEELOR_TRADE", "DID_SEELOR_TRADE" }, + [631] = { "FLAG_0x277", "0x277" }, + [632] = { "FLAG_GOT_HITMON_FROM_DOJO", "GOT_HITMON_FROM_DOJO" }, + [633] = { "FLAG_0x279", "0x279" }, + [634] = { "FLAG_SILPH_2F_DOOR_1", "SILPH_2F_DOOR_1" }, + [635] = { "FLAG_SILPH_2F_DOOR_2", "SILPH_2F_DOOR_2" }, + [636] = { "FLAG_SILPH_3F_DOOR_1", "SILPH_3F_DOOR_1" }, + [637] = { "FLAG_SILPH_3F_DOOR_2", "SILPH_3F_DOOR_2" }, + [638] = { "FLAG_SILPH_4F_DOOR_1", "SILPH_4F_DOOR_1" }, + [639] = { "FLAG_SILPH_4F_DOOR_2", "SILPH_4F_DOOR_2" }, + [640] = { "FLAG_SILPH_5F_DOOR_1", "SILPH_5F_DOOR_1" }, + [641] = { "FLAG_SILPH_5F_DOOR_2", "SILPH_5F_DOOR_2" }, + [642] = { "FLAG_SILPH_5F_DOOR_3", "SILPH_5F_DOOR_3" }, + [643] = { "FLAG_SILPH_6F_DOOR", "SILPH_6F_DOOR" }, + [644] = { "FLAG_SILPH_7F_DOOR_1", "SILPH_7F_DOOR_1" }, + [645] = { "FLAG_SILPH_7F_DOOR_2", "SILPH_7F_DOOR_2" }, + [646] = { "FLAG_SILPH_7F_DOOR_3", "SILPH_7F_DOOR_3" }, + [647] = { "FLAG_SILPH_8F_DOOR", "SILPH_8F_DOOR" }, + [648] = { "FLAG_SILPH_9F_DOOR_1", "SILPH_9F_DOOR_1" }, + [649] = { "FLAG_SILPH_9F_DOOR_2", "SILPH_9F_DOOR_2" }, + [650] = { "FLAG_SILPH_9F_DOOR_3", "SILPH_9F_DOOR_3" }, + [651] = { "FLAG_SILPH_9F_DOOR_4", "SILPH_9F_DOOR_4" }, + [652] = { "FLAG_SILPH_10F_DOOR", "SILPH_10F_DOOR" }, + [653] = { "FLAG_SILPH_11F_DOOR", "SILPH_11F_DOOR" }, + [654] = { "FLAG_0x28E", "0x28E" }, + [655] = { "FLAG_0x28F", "0x28F" }, + [656] = { "FLAG_MET_STICKER_MAN", "MET_STICKER_MAN" }, + [657] = { "FLAG_PALLET_LADY_NOT_BLOCKING_SIGN", "PALLET_LADY_NOT_BLOCKING_SIGN" }, + [658] = { "FLAG_GOT_VS_SEEKER", "GOT_VS_SEEKER" }, + [659] = { "FLAG_GOT_TM19_FROM_ERIKA", "GOT_TM19_FROM_ERIKA" }, + [660] = { "FLAG_GOT_TM33_FROM_THIRSTY_GIRL", "GOT_TM33_FROM_THIRSTY_GIRL" }, + [661] = { "FLAG_GOT_TM20_FROM_THIRSTY_GIRL", "GOT_TM20_FROM_THIRSTY_GIRL" }, + [662] = { "FLAG_GOT_TM16_FROM_THIRSTY_GIRL", "GOT_TM16_FROM_THIRSTY_GIRL" }, + [663] = { "FLAG_GOT_TM03_FROM_MISTY", "GOT_TM03_FROM_MISTY" }, + [664] = { "FLAG_GOT_TM26_FROM_GIOVANNI", "GOT_TM26_FROM_GIOVANNI" }, + [665] = { "FLAG_0x299", "0x299" }, + [666] = { "FLAG_GOT_TM04_FROM_SABRINA", "GOT_TM04_FROM_SABRINA" }, + [667] = { "FLAG_GOT_FAME_CHECKER", "GOT_FAME_CHECKER" }, + [668] = { "FLAG_GOT_RECORD_SETTING_MAGIKARP", "GOT_RECORD_SETTING_MAGIKARP" }, + [669] = { "FLAG_TWO_ISLAND_SHOP_INTRODUCED", "TWO_ISLAND_SHOP_INTRODUCED" }, + [670] = { "FLAG_TWO_ISLAND_SHOP_EXPANDED_1", "TWO_ISLAND_SHOP_EXPANDED_1" }, + [671] = { "FLAG_TWO_ISLAND_SHOP_EXPANDED_2", "TWO_ISLAND_SHOP_EXPANDED_2" }, + [672] = { "FLAG_TWO_ISLAND_SHOP_EXPANDED_3", "TWO_ISLAND_SHOP_EXPANDED_3" }, + [673] = { "FLAG_SEVII_DETOUR_FINISHED", "SEVII_DETOUR_FINISHED" }, + [674] = { "FLAG_VISITED_TWO_ISLAND", "VISITED_TWO_ISLAND" }, + [675] = { "FLAG_RESCUED_LOSTELLE", "RESCUED_LOSTELLE" }, + [676] = { "FLAG_0x2A4", "0x2A4" }, + [677] = { "FLAG_CAN_USE_ROCKET_HIDEOUT_LIFT", "CAN_USE_ROCKET_HIDEOUT_LIFT" }, + [678] = { "FLAG_GOT_TEA", "GOT_TEA" }, + [679] = { "FLAG_RECEIVED_AURORA_TICKET", "RECEIVED_AURORA_TICKET" }, + [680] = { "FLAG_RECEIVED_MYSTIC_TICKET", "RECEIVED_MYSTIC_TICKET" }, + [681] = { "FLAG_RECEIVED_OLD_SEA_MAP", "RECEIVED_OLD_SEA_MAP" }, + [682] = { "FLAG_WONDER_CARD_UNUSED_1", "WONDER_CARD_UNUSED_1" }, + [683] = { "FLAG_WONDER_CARD_UNUSED_2", "WONDER_CARD_UNUSED_2" }, + [684] = { "FLAG_WONDER_CARD_UNUSED_3", "WONDER_CARD_UNUSED_3" }, + [685] = { "FLAG_WONDER_CARD_UNUSED_4", "WONDER_CARD_UNUSED_4" }, + [686] = { "FLAG_WONDER_CARD_UNUSED_5", "WONDER_CARD_UNUSED_5" }, + [687] = { "FLAG_WONDER_CARD_UNUSED_6", "WONDER_CARD_UNUSED_6" }, + [688] = { "FLAG_WONDER_CARD_UNUSED_7", "WONDER_CARD_UNUSED_7" }, + [689] = { "FLAG_WONDER_CARD_UNUSED_8", "WONDER_CARD_UNUSED_8" }, + [690] = { "FLAG_WONDER_CARD_UNUSED_9", "WONDER_CARD_UNUSED_9" }, + [691] = { "FLAG_WONDER_CARD_UNUSED_10", "WONDER_CARD_UNUSED_10" }, + [692] = { "FLAG_WONDER_CARD_UNUSED_11", "WONDER_CARD_UNUSED_11" }, + [693] = { "FLAG_WONDER_CARD_UNUSED_12", "WONDER_CARD_UNUSED_12" }, + [694] = { "FLAG_WONDER_CARD_UNUSED_13", "WONDER_CARD_UNUSED_13" }, + [695] = { "FLAG_WONDER_CARD_UNUSED_14", "WONDER_CARD_UNUSED_14" }, + [696] = { "FLAG_WONDER_CARD_UNUSED_15", "WONDER_CARD_UNUSED_15" }, + [697] = { "FLAG_WONDER_CARD_UNUSED_16", "WONDER_CARD_UNUSED_16" }, + [698] = { "FLAG_WONDER_CARD_UNUSED_17", "WONDER_CARD_UNUSED_17" }, + [699] = { "FLAG_GOT_POWDER_JAR", "GOT_POWDER_JAR" }, + [700] = { "FLAG_FOUGHT_MEWTWO", "FOUGHT_MEWTWO" }, + [701] = { "FLAG_FOUGHT_MOLTRES", "FOUGHT_MOLTRES" }, + [702] = { "FLAG_FOUGHT_ARTICUNO", "FOUGHT_ARTICUNO" }, + [703] = { "FLAG_FOUGHT_ZAPDOS", "FOUGHT_ZAPDOS" }, + [704] = { "FLAG_TUTOR_DOUBLE_EDGE", "TUTOR_DOUBLE_EDGE" }, + [705] = { "FLAG_TUTOR_THUNDER_WAVE", "TUTOR_THUNDER_WAVE" }, + [706] = { "FLAG_TUTOR_ROCK_SLIDE", "TUTOR_ROCK_SLIDE" }, + [707] = { "FLAG_TUTOR_EXPLOSION", "TUTOR_EXPLOSION" }, + [708] = { "FLAG_TUTOR_MEGA_PUNCH", "TUTOR_MEGA_PUNCH" }, + [709] = { "FLAG_TUTOR_MEGA_KICK", "TUTOR_MEGA_KICK" }, + [710] = { "FLAG_TUTOR_DREAM_EATER", "TUTOR_DREAM_EATER" }, + [711] = { "FLAG_TUTOR_SOFT_BOILED", "TUTOR_SOFT_BOILED" }, + [712] = { "FLAG_TUTOR_SUBSTITUTE", "TUTOR_SUBSTITUTE" }, + [713] = { "FLAG_TUTOR_SWORDS_DANCE", "TUTOR_SWORDS_DANCE" }, + [714] = { "FLAG_TUTOR_SEISMIC_TOSS", "TUTOR_SEISMIC_TOSS" }, + [715] = { "FLAG_TUTOR_COUNTER", "TUTOR_COUNTER" }, + [716] = { "FLAG_TUTOR_METRONOME", "TUTOR_METRONOME" }, + [717] = { "FLAG_TUTOR_MIMIC", "TUTOR_MIMIC" }, + [718] = { "FLAG_TUTOR_BODY_SLAM", "TUTOR_BODY_SLAM" }, + [719] = { "FLAG_VISITED_OAKS_LAB", "VISITED_OAKS_LAB" }, + [720] = { "FLAG_FOUGHT_POWER_PLANT_ELECTRODE_1", "FOUGHT_POWER_PLANT_ELECTRODE_1" }, + [721] = { "FLAG_FOUGHT_POWER_PLANT_ELECTRODE_2", "FOUGHT_POWER_PLANT_ELECTRODE_2" }, + [722] = { "FLAG_STOPPED_SEAFOAM_B3F_CURRENT", "STOPPED_SEAFOAM_B3F_CURRENT" }, + [723] = { "FLAG_STOPPED_SEAFOAM_B4F_CURRENT", "STOPPED_SEAFOAM_B4F_CURRENT" }, + [724] = { "FLAG_TALKED_TO_LORELEI_AFTER_WAREHOUSE", "TALKED_TO_LORELEI_AFTER_WAREHOUSE" }, + [725] = { "FLAG_DEFEATED_ROCKETS_IN_WAREHOUSE", "DEFEATED_ROCKETS_IN_WAREHOUSE" }, + [726] = { "FLAG_UNLOCKED_ROCKET_WAREHOUSE", "UNLOCKED_ROCKET_WAREHOUSE" }, + [727] = { "FLAG_NO_ROOM_FOR_TM42_AT_MEMORIAL_PILLAR", "NO_ROOM_FOR_TM42_AT_MEMORIAL_PILLAR" }, + [728] = { "FLAG_LEARNED_YES_NAH_CHANSEY", "LEARNED_YES_NAH_CHANSEY" }, + [729] = { "FLAG_GOT_NEST_BALL_FROM_WATER_PATH_HOUSE_1", "GOT_NEST_BALL_FROM_WATER_PATH_HOUSE_1" }, + [730] = { "FLAG_GOT_TOGEPI_EGG", "GOT_TOGEPI_EGG" }, + [731] = { "FLAG_NO_ROOM_FOR_TOGEPI_EGG", "NO_ROOM_FOR_TOGEPI_EGG" }, + [732] = { "FLAG_RECOVERED_SAPPHIRE", "RECOVERED_SAPPHIRE" }, + [733] = { "FLAG_GOT_RUBY", "GOT_RUBY" }, + [734] = { "FLAG_TUTOR_FRENZY_PLANT", "TUTOR_FRENZY_PLANT" }, + [735] = { "FLAG_TUTOR_BLAST_BURN", "TUTOR_BLAST_BURN" }, + [736] = { "FLAG_TUTOR_HYDRO_CANNON", "TUTOR_HYDRO_CANNON" }, + [737] = { "FLAG_LEARNED_ALL_MOVES_AT_CAPE_BRINK", "LEARNED_ALL_MOVES_AT_CAPE_BRINK" }, + [738] = { "FLAG_GOT_NUGGET_FROM_DUNSPARCE_TUNNEL", "GOT_NUGGET_FROM_DUNSPARCE_TUNNEL" }, + [739] = { "FLAG_USED_CUT_ON_RUIN_VALLEY_BRAILLE", "USED_CUT_ON_RUIN_VALLEY_BRAILLE" }, + [740] = { "FLAG_FOUGHT_DEOXYS", "FOUGHT_DEOXYS" }, + [741] = { "FLAG_0x2E5", "0x2E5" }, + [742] = { "FLAG_0x2E6", "0x2E6" }, + [743] = { "FLAG_0x2E7", "0x2E7" }, + [744] = { "FLAG_0x2E8", "0x2E8" }, + [745] = { "FLAG_0x2E9", "0x2E9" }, + [746] = { "FLAG_0x2EA", "0x2EA" }, + [747] = { "FLAG_0x2EB", "0x2EB" }, + [748] = { "FLAG_REVIVED_DOME", "REVIVED_DOME" }, + [749] = { "FLAG_REVIVED_HELIX", "REVIVED_HELIX" }, + [750] = { "FLAG_REVIVED_AMBER", "REVIVED_AMBER" }, + [751] = { "FLAG_GOT_HM06", "GOT_HM06" }, + [752] = { "FLAG_SHOWN_MYSTIC_TICKET", "SHOWN_MYSTIC_TICKET" }, + [753] = { "FLAG_SHOWN_AURORA_TICKET", "SHOWN_AURORA_TICKET" }, + [754] = { "FLAG_FOUGHT_LUGIA", "FOUGHT_LUGIA" }, + [755] = { "FLAG_FOUGHT_HO_OH", "FOUGHT_HO_OH" }, + [756] = { "FLAG_OAK_SAW_DEX_COMPLETION", "OAK_SAW_DEX_COMPLETION" }, + [757] = { "FLAG_LUGIA_FLEW_AWAY", "LUGIA_FLEW_AWAY" }, + [758] = { "FLAG_HO_OH_FLEW_AWAY", "HO_OH_FLEW_AWAY" }, + [759] = { "FLAG_DEOXYS_FLEW_AWAY", "DEOXYS_FLEW_AWAY" }, + [760] = { "FLAG_TALKED_TO_TEA_LADY_AFTER_HOF", "TALKED_TO_TEA_LADY_AFTER_HOF" }, + [761] = { "FLAG_TALKED_TO_OAKS_AIDE_IN_VERMILION", "TALKED_TO_OAKS_AIDE_IN_VERMILION" }, + [762] = { "FLAG_GOT_EVERSTONE_FROM_OAKS_AIDE", "GOT_EVERSTONE_FROM_OAKS_AIDE" }, + [763] = { "FLAG_GOT_MOON_STONE_FROM_JOYFUL_GAME_CORNER", "GOT_MOON_STONE_FROM_JOYFUL_GAME_CORNER" }, + [764] = { "FLAG_GOT_FULL_RESTORE_FROM_THREE_ISLAND_DEFENDER", "GOT_FULL_RESTORE_FROM_THREE_ISLAND_DEFENDER" }, + [765] = { "FLAG_GOT_AMULET_COIN_FROM_OAKS_AIDE", "GOT_AMULET_COIN_FROM_OAKS_AIDE" }, + [766] = { "FLAG_NO_ROOM_FOR_JOYFUL_GAME_CORNER_MOON_STONE", "NO_ROOM_FOR_JOYFUL_GAME_CORNER_MOON_STONE" }, + [767] = { "FLAG_OAKS_RATING_IS_VIA_PC", "OAKS_RATING_IS_VIA_PC" }, + [768] = { "FLAG_0x300", "0x300" }, + [769] = { "FLAG_0x301", "0x301" }, + [770] = { "FLAG_0x302", "0x302" }, + [771] = { "FLAG_0x303", "0x303" }, + [772] = { "FLAG_0x304", "0x304" }, + [773] = { "FLAG_0x305", "0x305" }, + [774] = { "FLAG_0x306", "0x306" }, + [775] = { "FLAG_0x307", "0x307" }, + [776] = { "FLAG_0x308", "0x308" }, + [777] = { "FLAG_0x309", "0x309" }, + [778] = { "FLAG_0x30A", "0x30A" }, + [779] = { "FLAG_0x30B", "0x30B" }, + [780] = { "FLAG_0x30C", "0x30C" }, + [781] = { "FLAG_0x30D", "0x30D" }, + [782] = { "FLAG_0x30E", "0x30E" }, + [783] = { "FLAG_0x30F", "0x30F" }, + [784] = { "FLAG_0x310", "0x310" }, + [785] = { "FLAG_0x311", "0x311" }, + [786] = { "FLAG_0x312", "0x312" }, + [787] = { "FLAG_0x313", "0x313" }, + [788] = { "FLAG_0x314", "0x314" }, + [789] = { "FLAG_0x315", "0x315" }, + [790] = { "FLAG_0x316", "0x316" }, + [791] = { "FLAG_0x317", "0x317" }, + [792] = { "FLAG_0x318", "0x318" }, + [793] = { "FLAG_0x319", "0x319" }, + [794] = { "FLAG_0x31A", "0x31A" }, + [795] = { "FLAG_0x31B", "0x31B" }, + [796] = { "FLAG_0x31C", "0x31C" }, + [797] = { "FLAG_0x31D", "0x31D" }, + [798] = { "FLAG_0x31E", "0x31E" }, + [799] = { "FLAG_0x31F", "0x31F" }, + [800] = { "FLAG_0x320", "0x320" }, + [801] = { "FLAG_0x321", "0x321" }, + [802] = { "FLAG_0x322", "0x322" }, + [803] = { "FLAG_0x323", "0x323" }, + [804] = { "FLAG_0x324", "0x324" }, + [805] = { "FLAG_0x325", "0x325" }, + [806] = { "FLAG_0x326", "0x326" }, + [807] = { "FLAG_0x327", "0x327" }, + [808] = { "FLAG_0x328", "0x328" }, + [809] = { "FLAG_0x329", "0x329" }, + [810] = { "FLAG_0x32A", "0x32A" }, + [811] = { "FLAG_0x32B", "0x32B" }, + [812] = { "FLAG_0x32C", "0x32C" }, + [813] = { "FLAG_0x32D", "0x32D" }, + [814] = { "FLAG_0x32E", "0x32E" }, + [815] = { "FLAG_0x32F", "0x32F" }, + [816] = { "FLAG_0x330", "0x330" }, + [817] = { "FLAG_0x331", "0x331" }, + [818] = { "FLAG_0x332", "0x332" }, + [819] = { "FLAG_0x333", "0x333" }, + [820] = { "FLAG_0x334", "0x334" }, + [821] = { "FLAG_0x335", "0x335" }, + [822] = { "FLAG_0x336", "0x336" }, + [823] = { "FLAG_0x337", "0x337" }, + [824] = { "FLAG_0x338", "0x338" }, + [825] = { "FLAG_0x339", "0x339" }, + [826] = { "FLAG_0x33A", "0x33A" }, + [827] = { "FLAG_0x33B", "0x33B" }, + [828] = { "FLAG_0x33C", "0x33C" }, + [829] = { "FLAG_0x33D", "0x33D" }, + [830] = { "FLAG_0x33E", "0x33E" }, + [831] = { "FLAG_0x33F", "0x33F" }, + [832] = { "FLAG_0x340", "0x340" }, + [833] = { "FLAG_0x341", "0x341" }, + [834] = { "FLAG_0x342", "0x342" }, + [835] = { "FLAG_0x343", "0x343" }, + [836] = { "FLAG_0x344", "0x344" }, + [837] = { "FLAG_0x345", "0x345" }, + [838] = { "FLAG_0x346", "0x346" }, + [839] = { "FLAG_0x347", "0x347" }, + [840] = { "FLAG_0x348", "0x348" }, + [841] = { "FLAG_0x349", "0x349" }, + [842] = { "FLAG_0x34A", "0x34A" }, + [843] = { "FLAG_0x34B", "0x34B" }, + [844] = { "FLAG_0x34C", "0x34C" }, + [845] = { "FLAG_0x34D", "0x34D" }, + [846] = { "FLAG_0x34E", "0x34E" }, + [847] = { "FLAG_0x34F", "0x34F" }, + [848] = { "FLAG_0x350", "0x350" }, + [849] = { "FLAG_0x351", "0x351" }, + [850] = { "FLAG_0x352", "0x352" }, + [851] = { "FLAG_0x353", "0x353" }, + [852] = { "FLAG_0x354", "0x354" }, + [853] = { "FLAG_0x355", "0x355" }, + [854] = { "FLAG_0x356", "0x356" }, + [855] = { "FLAG_0x357", "0x357" }, + [856] = { "FLAG_0x358", "0x358" }, + [857] = { "FLAG_0x359", "0x359" }, + [858] = { "FLAG_0x35A", "0x35A" }, + [859] = { "FLAG_0x35B", "0x35B" }, + [860] = { "FLAG_0x35C", "0x35C" }, + [861] = { "FLAG_0x35D", "0x35D" }, + [862] = { "FLAG_0x35E", "0x35E" }, + [863] = { "FLAG_0x35F", "0x35F" }, + [864] = { "FLAG_0x360", "0x360" }, + [865] = { "FLAG_0x361", "0x361" }, + [866] = { "FLAG_0x362", "0x362" }, + [867] = { "FLAG_0x363", "0x363" }, + [868] = { "FLAG_0x364", "0x364" }, + [869] = { "FLAG_0x365", "0x365" }, + [870] = { "FLAG_0x366", "0x366" }, + [871] = { "FLAG_0x367", "0x367" }, + [872] = { "FLAG_0x368", "0x368" }, + [873] = { "FLAG_0x369", "0x369" }, + [874] = { "FLAG_0x36A", "0x36A" }, + [875] = { "FLAG_0x36B", "0x36B" }, + [876] = { "FLAG_0x36C", "0x36C" }, + [877] = { "FLAG_0x36D", "0x36D" }, + [878] = { "FLAG_0x36E", "0x36E" }, + [879] = { "FLAG_0x36F", "0x36F" }, + [880] = { "FLAG_0x370", "0x370" }, + [881] = { "FLAG_0x371", "0x371" }, + [882] = { "FLAG_0x372", "0x372" }, + [883] = { "FLAG_0x373", "0x373" }, + [884] = { "FLAG_0x374", "0x374" }, + [885] = { "FLAG_0x375", "0x375" }, + [886] = { "FLAG_0x376", "0x376" }, + [887] = { "FLAG_0x377", "0x377" }, + [888] = { "FLAG_0x378", "0x378" }, + [889] = { "FLAG_0x379", "0x379" }, + [890] = { "FLAG_0x37A", "0x37A" }, + [891] = { "FLAG_0x37B", "0x37B" }, + [892] = { "FLAG_0x37C", "0x37C" }, + [893] = { "FLAG_0x37D", "0x37D" }, + [894] = { "FLAG_0x37E", "0x37E" }, + [895] = { "FLAG_0x37F", "0x37F" }, + [896] = { "FLAG_0x380", "0x380" }, + [897] = { "FLAG_0x381", "0x381" }, + [898] = { "FLAG_0x382", "0x382" }, + [899] = { "FLAG_0x383", "0x383" }, + [900] = { "FLAG_0x384", "0x384" }, + [901] = { "FLAG_0x385", "0x385" }, + [902] = { "FLAG_0x386", "0x386" }, + [903] = { "FLAG_0x387", "0x387" }, + [904] = { "FLAG_0x388", "0x388" }, + [905] = { "FLAG_0x389", "0x389" }, + [906] = { "FLAG_0x38A", "0x38A" }, + [907] = { "FLAG_0x38B", "0x38B" }, + [908] = { "FLAG_0x38C", "0x38C" }, + [909] = { "FLAG_0x38D", "0x38D" }, + [910] = { "FLAG_0x38E", "0x38E" }, + [911] = { "FLAG_0x38F", "0x38F" }, + [912] = { "FLAG_0x390", "0x390" }, + [913] = { "FLAG_0x391", "0x391" }, + [914] = { "FLAG_0x392", "0x392" }, + [915] = { "FLAG_0x393", "0x393" }, + [916] = { "FLAG_0x394", "0x394" }, + [917] = { "FLAG_0x395", "0x395" }, + [918] = { "FLAG_0x396", "0x396" }, + [919] = { "FLAG_0x397", "0x397" }, + [920] = { "FLAG_0x398", "0x398" }, + [921] = { "FLAG_0x399", "0x399" }, + [922] = { "FLAG_0x39A", "0x39A" }, + [923] = { "FLAG_0x39B", "0x39B" }, + [924] = { "FLAG_0x39C", "0x39C" }, + [925] = { "FLAG_0x39D", "0x39D" }, + [926] = { "FLAG_0x39E", "0x39E" }, + [927] = { "FLAG_0x39F", "0x39F" }, + [928] = { "FLAG_0x3A0", "0x3A0" }, + [929] = { "FLAG_0x3A1", "0x3A1" }, + [930] = { "FLAG_0x3A2", "0x3A2" }, + [931] = { "FLAG_0x3A3", "0x3A3" }, + [932] = { "FLAG_0x3A4", "0x3A4" }, + [933] = { "FLAG_0x3A5", "0x3A5" }, + [934] = { "FLAG_0x3A6", "0x3A6" }, + [935] = { "FLAG_0x3A7", "0x3A7" }, + [936] = { "FLAG_0x3A8", "0x3A8" }, + [937] = { "FLAG_0x3A9", "0x3A9" }, + [938] = { "FLAG_0x3AA", "0x3AA" }, + [939] = { "FLAG_0x3AB", "0x3AB" }, + [940] = { "FLAG_0x3AC", "0x3AC" }, + [941] = { "FLAG_0x3AD", "0x3AD" }, + [942] = { "FLAG_0x3AE", "0x3AE" }, + [943] = { "FLAG_0x3AF", "0x3AF" }, + [944] = { "FLAG_0x3B0", "0x3B0" }, + [945] = { "FLAG_0x3B1", "0x3B1" }, + [946] = { "FLAG_0x3B2", "0x3B2" }, + [947] = { "FLAG_0x3B3", "0x3B3" }, + [948] = { "FLAG_0x3B4", "0x3B4" }, + [949] = { "FLAG_0x3B5", "0x3B5" }, + [950] = { "FLAG_0x3B6", "0x3B6" }, + [951] = { "FLAG_0x3B7", "0x3B7" }, + [952] = { "FLAG_0x3B8", "0x3B8" }, + [953] = { "FLAG_0x3B9", "0x3B9" }, + [954] = { "FLAG_0x3BA", "0x3BA" }, + [955] = { "FLAG_0x3BB", "0x3BB" }, + [956] = { "FLAG_0x3BC", "0x3BC" }, + [957] = { "FLAG_0x3BD", "0x3BD" }, + [958] = { "FLAG_0x3BE", "0x3BE" }, + [959] = { "FLAG_0x3BF", "0x3BF" }, + [960] = { "FLAG_0x3C0", "0x3C0" }, + [961] = { "FLAG_0x3C1", "0x3C1" }, + [962] = { "FLAG_0x3C2", "0x3C2" }, + [963] = { "FLAG_0x3C3", "0x3C3" }, + [964] = { "FLAG_0x3C4", "0x3C4" }, + [965] = { "FLAG_0x3C5", "0x3C5" }, + [966] = { "FLAG_0x3C6", "0x3C6" }, + [967] = { "FLAG_0x3C7", "0x3C7" }, + [968] = { "FLAG_0x3C8", "0x3C8" }, + [969] = { "FLAG_0x3C9", "0x3C9" }, + [970] = { "FLAG_0x3CA", "0x3CA" }, + [971] = { "FLAG_0x3CB", "0x3CB" }, + [972] = { "FLAG_0x3CC", "0x3CC" }, + [973] = { "FLAG_0x3CD", "0x3CD" }, + [974] = { "FLAG_0x3CE", "0x3CE" }, + [975] = { "FLAG_0x3CF", "0x3CF" }, + [976] = { "FLAG_0x3D0", "0x3D0" }, + [977] = { "FLAG_0x3D1", "0x3D1" }, + [978] = { "FLAG_0x3D2", "0x3D2" }, + [979] = { "FLAG_0x3D3", "0x3D3" }, + [980] = { "FLAG_0x3D4", "0x3D4" }, + [981] = { "FLAG_0x3D5", "0x3D5" }, + [982] = { "FLAG_0x3D6", "0x3D6" }, + [983] = { "FLAG_0x3D7", "0x3D7" }, + [984] = { "FLAG_MYSTERY_GIFT_DONE", "MYSTERY_GIFT_DONE" }, + [985] = { "FLAG_MYSTERY_GIFT_1", "MYSTERY_GIFT_1" }, + [986] = { "FLAG_MYSTERY_GIFT_2", "MYSTERY_GIFT_2" }, + [987] = { "FLAG_MYSTERY_GIFT_3", "MYSTERY_GIFT_3" }, + [988] = { "FLAG_MYSTERY_GIFT_4", "MYSTERY_GIFT_4" }, + [989] = { "FLAG_MYSTERY_GIFT_5", "MYSTERY_GIFT_5" }, + [990] = { "FLAG_MYSTERY_GIFT_6", "MYSTERY_GIFT_6" }, + [991] = { "FLAG_MYSTERY_GIFT_7", "MYSTERY_GIFT_7" }, + [992] = { "FLAG_MYSTERY_GIFT_8", "MYSTERY_GIFT_8" }, + [993] = { "FLAG_MYSTERY_GIFT_9", "MYSTERY_GIFT_9" }, + [994] = { "FLAG_MYSTERY_GIFT_10", "MYSTERY_GIFT_10" }, + [995] = { "FLAG_MYSTERY_GIFT_11", "MYSTERY_GIFT_11" }, + [996] = { "FLAG_MYSTERY_GIFT_12", "MYSTERY_GIFT_12" }, + [997] = { "FLAG_MYSTERY_GIFT_13", "MYSTERY_GIFT_13" }, + [998] = { "FLAG_MYSTERY_GIFT_14", "MYSTERY_GIFT_14" }, + [999] = { "FLAG_MYSTERY_GIFT_15", "MYSTERY_GIFT_15" }, + [1000] = { "FLAG_HIDDEN_ITEMS_START", "FLAG_HIDDEN_ITEM_VIRIDIAN_FOREST_POTION", "HIDDEN_ITEMS_START", "HIDDEN_ITEM_VIRIDIAN_FOREST_POTION" }, + [1001] = { "FLAG_HIDDEN_ITEM_VIRIDIAN_FOREST_ANTIDOTE", "HIDDEN_ITEM_VIRIDIAN_FOREST_ANTIDOTE" }, + [1002] = { "FLAG_HIDDEN_ITEM_MT_MOON_B2F_MOON_STONE", "HIDDEN_ITEM_MT_MOON_B2F_MOON_STONE" }, + [1003] = { "FLAG_HIDDEN_ITEM_MT_MOON_B2F_ETHER", "HIDDEN_ITEM_MT_MOON_B2F_ETHER" }, + [1004] = { "FLAG_HIDDEN_ITEM_ROUTE25_ELIXIR", "HIDDEN_ITEM_ROUTE25_ELIXIR" }, + [1005] = { "FLAG_HIDDEN_ITEM_ROUTE25_ETHER", "HIDDEN_ITEM_ROUTE25_ETHER" }, + [1006] = { "FLAG_HIDDEN_ITEM_ROUTE9_ETHER", "HIDDEN_ITEM_ROUTE9_ETHER" }, + [1007] = { "FLAG_HIDDEN_ITEM_UNUSED_0x07", "HIDDEN_ITEM_UNUSED_0x07" }, + [1008] = { "FLAG_HIDDEN_ITEM_SSANNE_B1F_CORRIDOR_HYPER_POTION", "HIDDEN_ITEM_SSANNE_B1F_CORRIDOR_HYPER_POTION" }, + [1009] = { "FLAG_HIDDEN_ITEM_ROUTE10_SUPER_POTION", "HIDDEN_ITEM_ROUTE10_SUPER_POTION" }, + [1010] = { "FLAG_HIDDEN_ITEM_ROUTE10_MAX_ETHER", "HIDDEN_ITEM_ROUTE10_MAX_ETHER" }, + [1011] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B1F_PP_UP", "HIDDEN_ITEM_ROCKET_HIDEOUT_B1F_PP_UP" }, + [1012] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B3F_NUGGET", "HIDDEN_ITEM_ROCKET_HIDEOUT_B3F_NUGGET" }, + [1013] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NEST_BALL", "HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NEST_BALL" }, + [1014] = { "FLAG_HIDDEN_ITEM_POKEMON_TOWER_5F_BIG_MUSHROOM", "HIDDEN_ITEM_POKEMON_TOWER_5F_BIG_MUSHROOM" }, + [1015] = { "FLAG_HIDDEN_ITEM_ROUTE13_PP_UP", "HIDDEN_ITEM_ROUTE13_PP_UP" }, + [1016] = { "FLAG_HIDDEN_ITEM_UNUSED_0x10", "HIDDEN_ITEM_UNUSED_0x10" }, + [1017] = { "FLAG_HIDDEN_ITEM_ROUTE17_RARE_CANDY", "HIDDEN_ITEM_ROUTE17_RARE_CANDY" }, + [1018] = { "FLAG_HIDDEN_ITEM_ROUTE17_FULL_RESTORE", "HIDDEN_ITEM_ROUTE17_FULL_RESTORE" }, + [1019] = { "FLAG_HIDDEN_ITEM_ROUTE17_PP_UP", "HIDDEN_ITEM_ROUTE17_PP_UP" }, + [1020] = { "FLAG_HIDDEN_ITEM_ROUTE17_MAX_REVIVE", "HIDDEN_ITEM_ROUTE17_MAX_REVIVE" }, + [1021] = { "FLAG_HIDDEN_ITEM_ROUTE17_MAX_ELIXIR", "HIDDEN_ITEM_ROUTE17_MAX_ELIXIR" }, + [1022] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_CENTER_LEAF_STONE", "HIDDEN_ITEM_SAFARI_ZONE_CENTER_LEAF_STONE" }, + [1023] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_WEST_REVIVE", "HIDDEN_ITEM_SAFARI_ZONE_WEST_REVIVE" }, + [1024] = { "FLAG_HIDDEN_ITEM_SILPH_CO_5F_ELIXIR", "HIDDEN_ITEM_SILPH_CO_5F_ELIXIR" }, + [1025] = { "FLAG_HIDDEN_ITEM_SILPH_CO_9F_MAX_POTION", "HIDDEN_ITEM_SILPH_CO_9F_MAX_POTION" }, + [1026] = { "FLAG_HIDDEN_ITEM_SAFFRON_CITY_COPYCATS_HOUSE_2F_NUGGET", "HIDDEN_ITEM_SAFFRON_CITY_COPYCATS_HOUSE_2F_NUGGET" }, + [1027] = { "FLAG_HIDDEN_ITEM_POWER_PLANT_MAX_ELIXIR", "HIDDEN_ITEM_POWER_PLANT_MAX_ELIXIR" }, + [1028] = { "FLAG_HIDDEN_ITEM_POWER_PLANT_THUNDER_STONE", "HIDDEN_ITEM_POWER_PLANT_THUNDER_STONE" }, + [1029] = { "FLAG_HIDDEN_ITEM_SEAFOAM_ISLANDS_B3F_NUGGET", "HIDDEN_ITEM_SEAFOAM_ISLANDS_B3F_NUGGET" }, + [1030] = { "FLAG_HIDDEN_ITEM_SEAFOAM_ISLANDS_B4F_WATER_STONE", "HIDDEN_ITEM_SEAFOAM_ISLANDS_B4F_WATER_STONE" }, + [1031] = { "FLAG_HIDDEN_ITEM_POKEMON_MANSION_1F_MOON_STONE", "HIDDEN_ITEM_POKEMON_MANSION_1F_MOON_STONE" }, + [1032] = { "FLAG_HIDDEN_ITEM_POKEMON_MANSION_3F_RARE_CANDY", "HIDDEN_ITEM_POKEMON_MANSION_3F_RARE_CANDY" }, + [1033] = { "FLAG_HIDDEN_ITEM_POKEMON_MANSION_B1F_ELIXIR", "HIDDEN_ITEM_POKEMON_MANSION_B1F_ELIXIR" }, + [1034] = { "FLAG_HIDDEN_ITEM_ROUTE23_FULL_RESTORE", "HIDDEN_ITEM_ROUTE23_FULL_RESTORE" }, + [1035] = { "FLAG_HIDDEN_ITEM_ROUTE23_ULTRA_BALL", "HIDDEN_ITEM_ROUTE23_ULTRA_BALL" }, + [1036] = { "FLAG_HIDDEN_ITEM_ROUTE23_MAX_ETHER", "HIDDEN_ITEM_ROUTE23_MAX_ETHER" }, + [1037] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL", "HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL" }, + [1038] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_1F_FULL_RESTORE", "HIDDEN_ITEM_VICTORY_ROAD_1F_FULL_RESTORE" }, + [1039] = { "FLAG_HIDDEN_ITEM_CERULEAN_CAVE_1F_ULTRA_BALL", "HIDDEN_ITEM_CERULEAN_CAVE_1F_ULTRA_BALL" }, + [1040] = { "FLAG_HIDDEN_ITEM_UNUSED_0x28", "HIDDEN_ITEM_UNUSED_0x28" }, + [1041] = { "FLAG_HIDDEN_ITEM_ROUTE11_ESCAPE_ROPE", "HIDDEN_ITEM_ROUTE11_ESCAPE_ROPE" }, + [1042] = { "FLAG_HIDDEN_ITEM_ROUTE12_HYPER_POTION", "HIDDEN_ITEM_ROUTE12_HYPER_POTION" }, + [1043] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2B", "HIDDEN_ITEM_UNUSED_0x2B" }, + [1044] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2C", "HIDDEN_ITEM_UNUSED_0x2C" }, + [1045] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2D", "HIDDEN_ITEM_UNUSED_0x2D" }, + [1046] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2E", "HIDDEN_ITEM_UNUSED_0x2E" }, + [1047] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_PP_UP", "HIDDEN_ITEM_CELADON_CITY_PP_UP" }, + [1048] = { "FLAG_HIDDEN_ITEM_VERMILION_CITY_MAX_ETHER", "HIDDEN_ITEM_VERMILION_CITY_MAX_ETHER" }, + [1049] = { "FLAG_HIDDEN_ITEM_CERULEAN_CITY_RARE_CANDY", "HIDDEN_ITEM_CERULEAN_CITY_RARE_CANDY" }, + [1050] = { "FLAG_HIDDEN_ITEM_ROUTE4_GREAT_BALL", "HIDDEN_ITEM_ROUTE4_GREAT_BALL" }, + [1051] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS" }, + [1052] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_2", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_2" }, + [1053] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_3", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_3" }, + [1054] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_4", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_4" }, + [1055] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_5", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_5" }, + [1056] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_6", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_6" }, + [1057] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_7", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_7" }, + [1058] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_8", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_8" }, + [1059] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_9", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_9" }, + [1060] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_10", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_10" }, + [1061] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_11", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_11" }, + [1062] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_12", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_12" }, + [1063] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_CHERI_BERRY", "HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_CHERI_BERRY" }, + [1064] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_4", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_4" }, + [1065] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE" }, + [1066] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_2", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_2" }, + [1067] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_3", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_3" }, + [1068] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NEST_BALL", "HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NEST_BALL" }, + [1069] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NET_BALL", "HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NET_BALL" }, + [1070] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_POTION", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_POTION" }, + [1071] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ANTIDOTE", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ANTIDOTE" }, + [1072] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_PARALYZE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_PARALYZE_HEAL" }, + [1073] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_AWAKENING", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_AWAKENING" }, + [1074] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_BURN_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_BURN_HEAL" }, + [1075] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ICE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ICE_HEAL" }, + [1076] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ETHER", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ETHER" }, + [1077] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_POTION", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_POTION" }, + [1078] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ANTIDOTE", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ANTIDOTE" }, + [1079] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_PARALYZE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_PARALYZE_HEAL" }, + [1080] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_AWAKENING", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_AWAKENING" }, + [1081] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_BURN_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_BURN_HEAL" }, + [1082] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ICE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ICE_HEAL" }, + [1083] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ETHER", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ETHER" }, + [1084] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM", "HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM" }, + [1085] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_2", "HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_2" }, + [1086] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_3", "HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_3" }, + [1087] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM", "HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM" }, + [1088] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_2", "HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_2" }, + [1089] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_3", "HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_3" }, + [1090] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAZZ_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAZZ_BERRY" }, + [1091] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_BLUK_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_BLUK_BERRY" }, + [1092] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_NANAB_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_NANAB_BERRY" }, + [1093] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_WEPEAR_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_WEPEAR_BERRY" }, + [1094] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ORAN_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ORAN_BERRY" }, + [1095] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHERI_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHERI_BERRY" }, + [1096] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHESTO_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHESTO_BERRY" }, + [1097] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PECHA_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PECHA_BERRY" }, + [1098] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAWST_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAWST_BERRY" }, + [1099] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ASPEAR_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ASPEAR_BERRY" }, + [1100] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PERSIM_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PERSIM_BERRY" }, + [1101] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PINAP_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PINAP_BERRY" }, + [1102] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_LUM_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_LUM_BERRY" }, + [1103] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST" }, + [1104] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST_2", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST_2" }, + [1105] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL" }, + [1106] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL_2", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL_2" }, + [1107] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL" }, + [1108] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL_2", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL_2" }, + [1109] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STAR_PIECE", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STAR_PIECE" }, + [1110] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_BIG_PEARL", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_BIG_PEARL" }, + [1111] = { "FLAG_HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_RARE_CANDY", "HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_RARE_CANDY" }, + [1112] = { "FLAG_HIDDEN_ITEM_PEWTER_CITY_POKE_BALL", "HIDDEN_ITEM_PEWTER_CITY_POKE_BALL" }, + [1113] = { "FLAG_HIDDEN_ITEM_ROUTE3_ORAN_BERRY", "HIDDEN_ITEM_ROUTE3_ORAN_BERRY" }, + [1114] = { "FLAG_HIDDEN_ITEM_ROUTE4_PERSIM_BERRY", "HIDDEN_ITEM_ROUTE4_PERSIM_BERRY" }, + [1115] = { "FLAG_HIDDEN_ITEM_ROUTE24_PECHA_BERRY", "HIDDEN_ITEM_ROUTE24_PECHA_BERRY" }, + [1116] = { "FLAG_HIDDEN_ITEM_ROUTE25_ORAN_BERRY", "HIDDEN_ITEM_ROUTE25_ORAN_BERRY" }, + [1117] = { "FLAG_HIDDEN_ITEM_ROUTE25_BLUK_BERRY", "HIDDEN_ITEM_ROUTE25_BLUK_BERRY" }, + [1118] = { "FLAG_HIDDEN_ITEM_ROUTE6_SITRUS_BERRY", "HIDDEN_ITEM_ROUTE6_SITRUS_BERRY" }, + [1119] = { "FLAG_HIDDEN_ITEM_ROUTE6_RARE_CANDY", "HIDDEN_ITEM_ROUTE6_RARE_CANDY" }, + [1120] = { "FLAG_HIDDEN_ITEM_SSANNE_KITCHEN_PECHA_BERRY", "HIDDEN_ITEM_SSANNE_KITCHEN_PECHA_BERRY" }, + [1121] = { "FLAG_HIDDEN_ITEM_SSANNE_KITCHEN_CHERI_BERRY", "HIDDEN_ITEM_SSANNE_KITCHEN_CHERI_BERRY" }, + [1122] = { "FLAG_HIDDEN_ITEM_SSANNE_KITCHEN_CHESTO_BERRY", "HIDDEN_ITEM_SSANNE_KITCHEN_CHESTO_BERRY" }, + [1123] = { "FLAG_HIDDEN_ITEM_ROUTE9_RARE_CANDY", "HIDDEN_ITEM_ROUTE9_RARE_CANDY" }, + [1124] = { "FLAG_HIDDEN_ITEM_UNUSED_0x7C", "HIDDEN_ITEM_UNUSED_0x7C" }, + [1125] = { "FLAG_HIDDEN_ITEM_ROUTE10_PERSIM_BERRY", "HIDDEN_ITEM_ROUTE10_PERSIM_BERRY" }, + [1126] = { "FLAG_HIDDEN_ITEM_ROUTE10_CHERI_BERRY", "HIDDEN_ITEM_ROUTE10_CHERI_BERRY" }, + [1127] = { "FLAG_HIDDEN_ITEM_ROUTE8_RAWST_BERRY", "HIDDEN_ITEM_ROUTE8_RAWST_BERRY" }, + [1128] = { "FLAG_HIDDEN_ITEM_ROUTE8_LUM_BERRY", "HIDDEN_ITEM_ROUTE8_LUM_BERRY" }, + [1129] = { "FLAG_HIDDEN_ITEM_ROUTE8_LEPPA_BERRY", "HIDDEN_ITEM_ROUTE8_LEPPA_BERRY" }, + [1130] = { "FLAG_HIDDEN_ITEM_ROUTE12_RARE_CANDY", "HIDDEN_ITEM_ROUTE12_RARE_CANDY" }, + [1131] = { "FLAG_HIDDEN_ITEM_ROUTE12_LEFTOVERS", "HIDDEN_ITEM_ROUTE12_LEFTOVERS" }, + [1132] = { "FLAG_HIDDEN_ITEM_ROUTE16_LEFTOVERS", "HIDDEN_ITEM_ROUTE16_LEFTOVERS" }, + [1133] = { "FLAG_HIDDEN_ITEM_FUCHSIA_CITY_MAX_REVIVE", "HIDDEN_ITEM_FUCHSIA_CITY_MAX_REVIVE" }, + [1134] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NET_BALL", "HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NET_BALL" }, + [1135] = { "FLAG_HIDDEN_ITEM_SILPH_CO_2F_ULTRA_BALL", "HIDDEN_ITEM_SILPH_CO_2F_ULTRA_BALL" }, + [1136] = { "FLAG_HIDDEN_ITEM_SILPH_CO_3F_PROTEIN", "HIDDEN_ITEM_SILPH_CO_3F_PROTEIN" }, + [1137] = { "FLAG_HIDDEN_ITEM_SILPH_CO_4F_IRON", "HIDDEN_ITEM_SILPH_CO_4F_IRON" }, + [1138] = { "FLAG_HIDDEN_ITEM_SILPH_CO_5F_PP_UP", "HIDDEN_ITEM_SILPH_CO_5F_PP_UP" }, + [1139] = { "FLAG_HIDDEN_ITEM_SILPH_CO_6F_CARBOS", "HIDDEN_ITEM_SILPH_CO_6F_CARBOS" }, + [1140] = { "FLAG_HIDDEN_ITEM_SILPH_CO_7F_ZINC", "HIDDEN_ITEM_SILPH_CO_7F_ZINC" }, + [1141] = { "FLAG_HIDDEN_ITEM_SILPH_CO_8F_NUGGET", "HIDDEN_ITEM_SILPH_CO_8F_NUGGET" }, + [1142] = { "FLAG_HIDDEN_ITEM_SILPH_CO_9F_CALCIUM", "HIDDEN_ITEM_SILPH_CO_9F_CALCIUM" }, + [1143] = { "FLAG_HIDDEN_ITEM_SILPH_CO_10F_HP_UP", "HIDDEN_ITEM_SILPH_CO_10F_HP_UP" }, + [1144] = { "FLAG_HIDDEN_ITEM_SILPH_CO_11F_REVIVE", "HIDDEN_ITEM_SILPH_CO_11F_REVIVE" }, + [1145] = { "FLAG_HIDDEN_ITEM_ROUTE23_LUM_BERRY", "HIDDEN_ITEM_ROUTE23_LUM_BERRY" }, + [1146] = { "FLAG_HIDDEN_ITEM_ROUTE23_SITRUS_BERRY", "HIDDEN_ITEM_ROUTE23_SITRUS_BERRY" }, + [1147] = { "FLAG_HIDDEN_ITEM_ROUTE23_ASPEAR_BERRY", "HIDDEN_ITEM_ROUTE23_ASPEAR_BERRY" }, + [1148] = { "FLAG_HIDDEN_ITEM_ROUTE23_LEPPA_BERRY", "HIDDEN_ITEM_ROUTE23_LEPPA_BERRY" }, + [1149] = { "FLAG_HIDDEN_ITEM_ROUTE14_ZINC", "HIDDEN_ITEM_ROUTE14_ZINC" }, + [1150] = { "FLAG_HIDDEN_ITEM_ROUTE9_CHESTO_BERRY", "HIDDEN_ITEM_ROUTE9_CHESTO_BERRY" }, + [1151] = { "FLAG_HIDDEN_ITEM_ROUTE10_NANAB_BERRY", "HIDDEN_ITEM_ROUTE10_NANAB_BERRY" }, + [1152] = { "FLAG_HIDDEN_ITEM_ROUTE7_WEPEAR_BERRY", "HIDDEN_ITEM_ROUTE7_WEPEAR_BERRY" }, + [1153] = { "FLAG_HIDDEN_ITEM_ROUTE20_STARDUST", "HIDDEN_ITEM_ROUTE20_STARDUST" }, + [1154] = { "FLAG_HIDDEN_ITEM_ROUTE21_NORTH_PEARL", "HIDDEN_ITEM_ROUTE21_NORTH_PEARL" }, + [1155] = { "FLAG_HIDDEN_ITEM_ROUTE23_MAX_ELIXIR", "HIDDEN_ITEM_ROUTE23_MAX_ELIXIR" }, + [1156] = { "FLAG_HIDDEN_ITEM_ROUTE4_RAZZ_BERRY", "HIDDEN_ITEM_ROUTE4_RAZZ_BERRY" }, + [1157] = { "FLAG_HIDDEN_ITEM_ROUTE14_PINAP_BERRY", "HIDDEN_ITEM_ROUTE14_PINAP_BERRY" }, + [1158] = { "FLAG_HIDDEN_ITEM_MT_EMBER_EXTERIOR_FIRE_STONE", "HIDDEN_ITEM_MT_EMBER_EXTERIOR_FIRE_STONE" }, + [1159] = { "FLAG_HIDDEN_ITEM_POKEMON_TOWER_7F_SOOTHE_BELL", "HIDDEN_ITEM_POKEMON_TOWER_7F_SOOTHE_BELL" }, + [1160] = { "FLAG_HIDDEN_ITEM_NAVEL_ROCK_SUMMIT_SACRED_ASH", "HIDDEN_ITEM_NAVEL_ROCK_SUMMIT_SACRED_ASH" }, + [1161] = { "FLAG_HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_PP_MAX", "HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_PP_MAX" }, + [1162] = { "FLAG_HIDDEN_ITEM_MT_EMBER_EXTERIOR_ULTRA_BALL", "HIDDEN_ITEM_MT_EMBER_EXTERIOR_ULTRA_BALL" }, + [1163] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_DUNSPARCE_TUNNEL_NUGGET", "HIDDEN_ITEM_THREE_ISLAND_DUNSPARCE_TUNNEL_NUGGET" }, + [1164] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_PP_UP", "HIDDEN_ITEM_THREE_ISLAND_PP_UP" }, + [1165] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_MAX_REPEL", "HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_MAX_REPEL" }, + [1166] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_PEARL", "HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_PEARL" }, + [1167] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_STARDUST", "HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_STARDUST" }, + [1168] = { "FLAG_HIDDEN_ITEM_FOUR_ISLAND_PEARL", "HIDDEN_ITEM_FOUR_ISLAND_PEARL" }, + [1169] = { "FLAG_HIDDEN_ITEM_FOUR_ISLAND_ULTRA_BALL", "HIDDEN_ITEM_FOUR_ISLAND_ULTRA_BALL" }, + [1170] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BIG_PEARL", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BIG_PEARL" }, + [1171] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_RAZZ_BERRY", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_RAZZ_BERRY" }, + [1172] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_SITRUS_BERRY", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_SITRUS_BERRY" }, + [1173] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BLUK_BERRY", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BLUK_BERRY" }, + [1174] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_NEST_BALL", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_NEST_BALL" }, + [1175] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST" }, + [1176] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STAR_PIECE", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STAR_PIECE" }, + [1177] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST_2", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST_2" }, + [1178] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_STAR_PIECE", "HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_STAR_PIECE" }, + [1179] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_NET_BALL", "HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_NET_BALL" }, + [1180] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_GREEN_PATH_ULTRA_BALL", "HIDDEN_ITEM_SIX_ISLAND_GREEN_PATH_ULTRA_BALL" }, + [1181] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ASPEAR_BERRY", "HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ASPEAR_BERRY" }, + [1182] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ORAN_BERRY", "HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ORAN_BERRY" }, + [1183] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_PINAP_BERRY", "HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_PINAP_BERRY" }, + [1184] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_LEPPA_BERRY", "HIDDEN_ITEM_SIX_ISLAND_LEPPA_BERRY" }, + [1185] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_BIG_PEARL", "HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_BIG_PEARL" }, + [1186] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_PEARL", "HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_PEARL" }, + [1187] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_NANAB_BERRY", "HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_NANAB_BERRY" }, + [1188] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_ENTRANCE_RAWST_BERRY", "HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_ENTRANCE_RAWST_BERRY" }, + [1189] = { "FLAG_HIDDEN_ITEM_VIRIDIAN_CITY_GYM_MACHO_BRACE", "HIDDEN_ITEM_VIRIDIAN_CITY_GYM_MACHO_BRACE" }, + [1190] = { "FLAG_HIDDEN_ITEM_SSANNE_EXTERIOR_LAVA_COOKIE", "HIDDEN_ITEM_SSANNE_EXTERIOR_LAVA_COOKIE" }, + [1191] = { "FLAG_UNUSED_0x4A7", "UNUSED_0x4A7" }, + [1192] = { "FLAG_UNUSED_0x4A8", "UNUSED_0x4A8" }, + [1193] = { "FLAG_UNUSED_0x4A9", "UNUSED_0x4A9" }, + [1194] = { "FLAG_UNUSED_0x4AA", "UNUSED_0x4AA" }, + [1195] = { "FLAG_UNUSED_0x4AB", "UNUSED_0x4AB" }, + [1196] = { "FLAG_UNUSED_0x4AC", "UNUSED_0x4AC" }, + [1197] = { "FLAG_UNUSED_0x4AD", "UNUSED_0x4AD" }, + [1198] = { "FLAG_UNUSED_0x4AE", "UNUSED_0x4AE" }, + [1199] = { "FLAG_UNUSED_0x4AF", "UNUSED_0x4AF" }, + [1200] = { "FLAG_DEFEATED_BROCK", "DEFEATED_BROCK" }, + [1201] = { "FLAG_DEFEATED_MISTY", "DEFEATED_MISTY" }, + [1202] = { "FLAG_DEFEATED_LT_SURGE", "DEFEATED_LT_SURGE" }, + [1203] = { "FLAG_DEFEATED_ERIKA", "DEFEATED_ERIKA" }, + [1204] = { "FLAG_DEFEATED_KOGA", "DEFEATED_KOGA" }, + [1205] = { "FLAG_DEFEATED_SABRINA", "DEFEATED_SABRINA" }, + [1206] = { "FLAG_DEFEATED_BLAINE", "DEFEATED_BLAINE" }, + [1207] = { "FLAG_DEFEATED_LEADER_GIOVANNI", "DEFEATED_LEADER_GIOVANNI" }, + [1208] = { "FLAG_DEFEATED_LORELEI", "DEFEATED_LORELEI" }, + [1209] = { "FLAG_DEFEATED_BRUNO", "DEFEATED_BRUNO" }, + [1210] = { "FLAG_DEFEATED_AGATHA", "DEFEATED_AGATHA" }, + [1211] = { "FLAG_DEFEATED_LANCE", "DEFEATED_LANCE" }, + [1212] = { "FLAG_DEFEATED_CHAMP", "DEFEATED_CHAMP" }, + [1213] = { "FLAG_0x4BD", "0x4BD" }, + [1214] = { "FLAG_0x4BE", "0x4BE" }, + [1215] = { "FLAG_0x4BF", "0x4BF" }, + [1216] = { "FLAG_0x4C0", "0x4C0" }, + [1217] = { "FLAG_0x4C1", "0x4C1" }, + [1218] = { "FLAG_0x4C2", "0x4C2" }, + [1219] = { "FLAG_0x4C3", "0x4C3" }, + [1220] = { "FLAG_0x4C4", "0x4C4" }, + [1221] = { "FLAG_0x4C5", "0x4C5" }, + [1222] = { "FLAG_0x4C6", "0x4C6" }, + [1223] = { "FLAG_0x4C7", "0x4C7" }, + [1224] = { "FLAG_0x4C8", "0x4C8" }, + [1225] = { "FLAG_0x4C9", "0x4C9" }, + [1226] = { "FLAG_0x4CA", "0x4CA" }, + [1227] = { "FLAG_0x4CB", "0x4CB" }, + [1228] = { "FLAG_0x4CC", "0x4CC" }, + [1229] = { "FLAG_0x4CD", "0x4CD" }, + [1230] = { "FLAG_0x4CE", "0x4CE" }, + [1231] = { "FLAG_0x4CF", "0x4CF" }, + [1232] = { "FLAG_0x4D0", "0x4D0" }, + [1233] = { "FLAG_0x4D1", "0x4D1" }, + [1234] = { "FLAG_0x4D2", "0x4D2" }, + [1235] = { "FLAG_0x4D3", "0x4D3" }, + [1236] = { "FLAG_0x4D4", "0x4D4" }, + [1237] = { "FLAG_0x4D5", "0x4D5" }, + [1238] = { "FLAG_0x4D6", "0x4D6" }, + [1239] = { "FLAG_0x4D7", "0x4D7" }, + [1240] = { "FLAG_0x4D8", "0x4D8" }, + [1241] = { "FLAG_0x4D9", "0x4D9" }, + [1242] = { "FLAG_0x4DA", "0x4DA" }, + [1243] = { "FLAG_0x4DB", "0x4DB" }, + [1244] = { "FLAG_0x4DC", "0x4DC" }, + [1245] = { "FLAG_0x4DD", "0x4DD" }, + [1246] = { "FLAG_0x4DE", "0x4DE" }, + [1247] = { "FLAG_0x4DF", "0x4DF" }, + [1248] = { "FLAG_0x4E0", "0x4E0" }, + [1249] = { "FLAG_0x4E1", "0x4E1" }, + [1250] = { "FLAG_0x4E2", "0x4E2" }, + [1251] = { "FLAG_0x4E3", "0x4E3" }, + [1252] = { "FLAG_0x4E4", "0x4E4" }, + [1253] = { "FLAG_0x4E5", "0x4E5" }, + [1254] = { "FLAG_0x4E6", "0x4E6" }, + [1255] = { "FLAG_0x4E7", "0x4E7" }, + [1256] = { "FLAG_0x4E8", "0x4E8" }, + [1257] = { "FLAG_0x4E9", "0x4E9" }, + [1258] = { "FLAG_0x4EA", "0x4EA" }, + [1259] = { "FLAG_0x4EB", "0x4EB" }, + [1260] = { "FLAG_0x4EC", "0x4EC" }, + [1261] = { "FLAG_0x4ED", "0x4ED" }, + [1262] = { "FLAG_0x4EE", "0x4EE" }, + [1263] = { "FLAG_0x4EF", "0x4EF" }, + [1264] = { "FLAG_0x4F0", "0x4F0" }, + [1265] = { "FLAG_0x4F1", "0x4F1" }, + [1266] = { "FLAG_0x4F2", "0x4F2" }, + [1267] = { "FLAG_0x4F3", "0x4F3" }, + [1268] = { "FLAG_0x4F4", "0x4F4" }, + [1269] = { "FLAG_0x4F5", "0x4F5" }, + [1270] = { "FLAG_0x4F6", "0x4F6" }, + [1271] = { "FLAG_0x4F7", "0x4F7" }, + [1272] = { "FLAG_0x4F8", "0x4F8" }, + [1273] = { "FLAG_0x4F9", "0x4F9" }, + [1274] = { "FLAG_0x4FA", "0x4FA" }, + [1275] = { "FLAG_0x4FB", "0x4FB" }, + [1276] = { "FLAG_0x4FC", "0x4FC" }, + [1277] = { "FLAG_0x4FD", "0x4FD" }, + [1278] = { "FLAG_0x4FE", "0x4FE" }, + [1279] = { "FLAG_0x4FF", "0x4FF" }, + [1280] = { false, "TRAINER_FLAGS_START" }, + [2047] = { false, "TRAINER_FLAGS_END" }, + [2048] = { "FLAG_SYS_SAFARI_MODE", "SYS_FLAGS", "SYS_SAFARI_MODE" }, + [2049] = { "FLAG_SYS_VS_SEEKER_CHARGING", "SYS_VS_SEEKER_CHARGING" }, + [2050] = { "FLAG_SYS_CRUISE_MODE", "SYS_CRUISE_MODE" }, + [2051] = { "FLAG_SYS_WHITE_FLUTE_ACTIVE", "SYS_WHITE_FLUTE_ACTIVE" }, + [2052] = { "FLAG_SYS_BLACK_FLUTE_ACTIVE", "SYS_BLACK_FLUTE_ACTIVE" }, + [2053] = { "FLAG_SYS_USE_STRENGTH", "SYS_USE_STRENGTH" }, + [2054] = { "FLAG_SYS_FLASH_ACTIVE", "SYS_FLASH_ACTIVE" }, + [2055] = { "FLAG_SYS_SPECIAL_WILD_BATTLE", "SYS_SPECIAL_WILD_BATTLE" }, + [2056] = { "FLAG_SYS_QL_DEPARTED", "SYS_QL_DEPARTED" }, + [2057] = { "FLAG_0x809", "0x809" }, + [2058] = { "FLAG_0x80A", "0x80A" }, + [2059] = { "FLAG_0x80B", "0x80B" }, + [2060] = { "FLAG_0x80C", "0x80C" }, + [2061] = { "FLAG_0x80D", "0x80D" }, + [2062] = { "FLAG_0x80E", "0x80E" }, + [2063] = { "FLAG_0x80F", "0x80F" }, + [2064] = { "FLAG_0x810", "0x810" }, + [2065] = { "FLAG_0x811", "0x811" }, + [2066] = { "FLAG_0x812", "0x812" }, + [2067] = { "FLAG_0x813", "0x813" }, + [2068] = { "FLAG_0x814", "0x814" }, + [2069] = { "FLAG_0x815", "0x815" }, + [2070] = { "FLAG_0x816", "0x816" }, + [2071] = { "FLAG_0x817", "0x817" }, + [2072] = { "FLAG_0x818", "0x818" }, + [2073] = { "FLAG_0x819", "0x819" }, + [2074] = { "FLAG_0x81A", "0x81A" }, + [2075] = { "FLAG_0x81B", "0x81B" }, + [2076] = { "FLAG_0x81C", "0x81C" }, + [2077] = { "FLAG_0x81D", "0x81D" }, + [2078] = { "FLAG_0x81E", "0x81E" }, + [2079] = { "FLAG_0x81F", "0x81F" }, + [2080] = { "FLAG_BADGE01_GET", "BADGE01_GET", "PERMA_SYS_FLAGS_START" }, + [2081] = { "FLAG_BADGE02_GET", "BADGE02_GET" }, + [2082] = { "FLAG_BADGE03_GET", "BADGE03_GET" }, + [2083] = { "FLAG_BADGE04_GET", "BADGE04_GET" }, + [2084] = { "FLAG_BADGE05_GET", "BADGE05_GET" }, + [2085] = { "FLAG_BADGE06_GET", "BADGE06_GET" }, + [2086] = { "FLAG_BADGE07_GET", "BADGE07_GET" }, + [2087] = { "FLAG_BADGE08_GET", "BADGE08_GET" }, + [2088] = { "FLAG_SYS_POKEMON_GET", "SYS_POKEMON_GET" }, + [2089] = { "FLAG_SYS_POKEDEX_GET", "SYS_POKEDEX_GET" }, + [2090] = { "FLAG_0x82A", "0x82A" }, + [2091] = { "FLAG_0x82B", "0x82B" }, + [2092] = { "FLAG_SYS_GAME_CLEAR", "SYS_GAME_CLEAR" }, + [2093] = { "FLAG_SYS_SET_TRAINER_CARD_PROFILE", "SYS_SET_TRAINER_CARD_PROFILE" }, + [2094] = { "FLAG_0x82E", "0x82E" }, + [2095] = { "FLAG_SYS_B_DASH", "SYS_B_DASH" }, + [2096] = { "FLAG_SYS_ON_CYCLING_ROAD", "SYS_ON_CYCLING_ROAD" }, + [2097] = { "FLAG_0x831", "0x831" }, + [2098] = { "FLAG_0x832", "0x832" }, + [2099] = { "FLAG_0x833", "0x833" }, + [2100] = { "FLAG_SYS_NOT_SOMEONES_PC", "SYS_NOT_SOMEONES_PC" }, + [2101] = { "FLAG_0x835", "0x835" }, + [2102] = { "FLAG_0x836", "0x836" }, + [2103] = { "FLAG_SYS_RESET_RTC_ENABLE", "SYS_RESET_RTC_ENABLE" }, + [2104] = { "FLAG_0x838", "0x838" }, + [2105] = { "FLAG_SYS_MYSTERY_GIFT_ENABLED", "SYS_MYSTERY_GIFT_ENABLED" }, + [2106] = { "FLAG_0x83A", "0x83A" }, + [2107] = { "FLAG_SYS_RIBBON_GET", "SYS_RIBBON_GET" }, + [2108] = { "FLAG_SYS_SAW_HELP_SYSTEM_INTRO", "SYS_SAW_HELP_SYSTEM_INTRO" }, + [2109] = { "FLAG_0x83D", "0x83D" }, + [2110] = { "FLAG_OPENED_START_MENU", "OPENED_START_MENU" }, + [2111] = { "FLAG_0x83F", "0x83F" }, + [2112] = { "FLAG_SYS_NATIONAL_DEX", "SYS_NATIONAL_DEX" }, + [2113] = { "FLAG_SYS_PC_STORAGE_DISABLED", "SYS_PC_STORAGE_DISABLED" }, + [2114] = { "FLAG_SYS_INFORMED_OF_LOCAL_WIRELESS_PLAYER", "SYS_INFORMED_OF_LOCAL_WIRELESS_PLAYER" }, + [2115] = { "FLAG_SHOWN_BOX_WAS_FULL_MESSAGE", "SHOWN_BOX_WAS_FULL_MESSAGE" }, + [2116] = { "FLAG_SYS_CAN_LINK_WITH_RS", "SYS_CAN_LINK_WITH_RS" }, + [2117] = { "FLAG_SYS_SEVII_MAP_123", "SYS_SEVII_MAP_123" }, + [2118] = { "FLAG_SYS_SEVII_MAP_4567", "SYS_SEVII_MAP_4567" }, + [2119] = { "FLAG_SYS_GOT_BERRY_POUCH", "SYS_GOT_BERRY_POUCH" }, + [2120] = { "FLAG_SYS_DEOXYS_AWAKENED", "SYS_DEOXYS_AWAKENED" }, + [2121] = { "FLAG_SYS_UNLOCKED_TANOBY_RUINS", "SYS_UNLOCKED_TANOBY_RUINS" }, + [2122] = { "FLAG_ENABLE_SHIP_NAVEL_ROCK", "ENABLE_SHIP_NAVEL_ROCK" }, + [2123] = { "FLAG_ENABLE_SHIP_BIRTH_ISLAND", "ENABLE_SHIP_BIRTH_ISLAND" }, + [2192] = { "FLAG_WORLD_MAP_PALLET_TOWN", "WORLD_MAP_PALLET_TOWN" }, + [2193] = { "FLAG_WORLD_MAP_VIRIDIAN_CITY", "WORLD_MAP_VIRIDIAN_CITY" }, + [2194] = { "FLAG_WORLD_MAP_PEWTER_CITY", "WORLD_MAP_PEWTER_CITY" }, + [2195] = { "FLAG_WORLD_MAP_CERULEAN_CITY", "WORLD_MAP_CERULEAN_CITY" }, + [2196] = { "FLAG_WORLD_MAP_LAVENDER_TOWN", "WORLD_MAP_LAVENDER_TOWN" }, + [2197] = { "FLAG_WORLD_MAP_VERMILION_CITY", "WORLD_MAP_VERMILION_CITY" }, + [2198] = { "FLAG_WORLD_MAP_CELADON_CITY", "WORLD_MAP_CELADON_CITY" }, + [2199] = { "FLAG_WORLD_MAP_FUCHSIA_CITY", "WORLD_MAP_FUCHSIA_CITY" }, + [2200] = { "FLAG_WORLD_MAP_CINNABAR_ISLAND", "WORLD_MAP_CINNABAR_ISLAND" }, + [2201] = { "FLAG_WORLD_MAP_INDIGO_PLATEAU_EXTERIOR", "WORLD_MAP_INDIGO_PLATEAU_EXTERIOR" }, + [2202] = { "FLAG_WORLD_MAP_SAFFRON_CITY", "WORLD_MAP_SAFFRON_CITY" }, + [2203] = { "FLAG_WORLD_MAP_ONE_ISLAND", "WORLD_MAP_ONE_ISLAND" }, + [2204] = { "FLAG_WORLD_MAP_TWO_ISLAND", "WORLD_MAP_TWO_ISLAND" }, + [2205] = { "FLAG_WORLD_MAP_THREE_ISLAND", "WORLD_MAP_THREE_ISLAND" }, + [2206] = { "FLAG_WORLD_MAP_FOUR_ISLAND", "WORLD_MAP_FOUR_ISLAND" }, + [2207] = { "FLAG_WORLD_MAP_FIVE_ISLAND", "WORLD_MAP_FIVE_ISLAND" }, + [2208] = { "FLAG_WORLD_MAP_SEVEN_ISLAND", "WORLD_MAP_SEVEN_ISLAND" }, + [2209] = { "FLAG_WORLD_MAP_SIX_ISLAND", "WORLD_MAP_SIX_ISLAND" }, + [2210] = { "FLAG_WORLD_MAP_ROUTE4_POKEMON_CENTER_1F", "WORLD_MAP_ROUTE4_POKEMON_CENTER_1F" }, + [2211] = { "FLAG_WORLD_MAP_ROUTE10_POKEMON_CENTER_1F", "WORLD_MAP_ROUTE10_POKEMON_CENTER_1F" }, + [2212] = { "FLAG_WORLD_MAP_VIRIDIAN_FOREST", "WORLD_MAP_VIRIDIAN_FOREST" }, + [2213] = { "FLAG_WORLD_MAP_MT_MOON_1F", "WORLD_MAP_MT_MOON_1F" }, + [2214] = { "FLAG_WORLD_MAP_SSANNE_EXTERIOR", "WORLD_MAP_SSANNE_EXTERIOR" }, + [2215] = { "FLAG_WORLD_MAP_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL", "WORLD_MAP_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL" }, + [2216] = { "FLAG_WORLD_MAP_UNDERGROUND_PATH_EAST_WEST_TUNNEL", "WORLD_MAP_UNDERGROUND_PATH_EAST_WEST_TUNNEL" }, + [2217] = { "FLAG_WORLD_MAP_DIGLETTS_CAVE_B1F", "WORLD_MAP_DIGLETTS_CAVE_B1F" }, + [2218] = { "FLAG_WORLD_MAP_VICTORY_ROAD_1F", "WORLD_MAP_VICTORY_ROAD_1F" }, + [2219] = { "FLAG_WORLD_MAP_ROCKET_HIDEOUT_B1F", "WORLD_MAP_ROCKET_HIDEOUT_B1F" }, + [2220] = { "FLAG_WORLD_MAP_SILPH_CO_1F", "WORLD_MAP_SILPH_CO_1F" }, + [2221] = { "FLAG_WORLD_MAP_POKEMON_MANSION_1F", "WORLD_MAP_POKEMON_MANSION_1F" }, + [2222] = { "FLAG_WORLD_MAP_SAFARI_ZONE_CENTER", "WORLD_MAP_SAFARI_ZONE_CENTER" }, + [2223] = { "FLAG_WORLD_MAP_POKEMON_LEAGUE_LORELEIS_ROOM", "WORLD_MAP_POKEMON_LEAGUE_LORELEIS_ROOM" }, + [2224] = { "FLAG_WORLD_MAP_ROCK_TUNNEL_1F", "WORLD_MAP_ROCK_TUNNEL_1F" }, + [2225] = { "FLAG_WORLD_MAP_SEAFOAM_ISLANDS_1F", "WORLD_MAP_SEAFOAM_ISLANDS_1F" }, + [2226] = { "FLAG_WORLD_MAP_POKEMON_TOWER_1F", "WORLD_MAP_POKEMON_TOWER_1F" }, + [2227] = { "FLAG_WORLD_MAP_CERULEAN_CAVE_1F", "WORLD_MAP_CERULEAN_CAVE_1F" }, + [2228] = { "FLAG_WORLD_MAP_POWER_PLANT", "WORLD_MAP_POWER_PLANT" }, + [2229] = { "FLAG_WORLD_MAP_NAVEL_ROCK_EXTERIOR", "WORLD_MAP_NAVEL_ROCK_EXTERIOR" }, + [2230] = { "FLAG_WORLD_MAP_MT_EMBER_EXTERIOR", "WORLD_MAP_MT_EMBER_EXTERIOR" }, + [2231] = { "FLAG_WORLD_MAP_THREE_ISLAND_BERRY_FOREST", "WORLD_MAP_THREE_ISLAND_BERRY_FOREST" }, + [2232] = { "FLAG_WORLD_MAP_FOUR_ISLAND_ICEFALL_CAVE_ENTRANCE", "WORLD_MAP_FOUR_ISLAND_ICEFALL_CAVE_ENTRANCE" }, + [2233] = { "FLAG_WORLD_MAP_FIVE_ISLAND_ROCKET_WAREHOUSE", "WORLD_MAP_FIVE_ISLAND_ROCKET_WAREHOUSE" }, + [2234] = { "FLAG_WORLD_MAP_TRAINER_TOWER_LOBBY", "WORLD_MAP_TRAINER_TOWER_LOBBY" }, + [2235] = { "FLAG_WORLD_MAP_SIX_ISLAND_DOTTED_HOLE_1F", "WORLD_MAP_SIX_ISLAND_DOTTED_HOLE_1F" }, + [2236] = { "FLAG_WORLD_MAP_FIVE_ISLAND_LOST_CAVE_ENTRANCE", "WORLD_MAP_FIVE_ISLAND_LOST_CAVE_ENTRANCE" }, + [2237] = { "FLAG_WORLD_MAP_SIX_ISLAND_PATTERN_BUSH", "WORLD_MAP_SIX_ISLAND_PATTERN_BUSH" }, + [2238] = { "FLAG_WORLD_MAP_SIX_ISLAND_ALTERING_CAVE", "WORLD_MAP_SIX_ISLAND_ALTERING_CAVE" }, + [2239] = { "FLAG_WORLD_MAP_SEVEN_ISLAND_TANOBY_RUINS_MONEAN_CHAMBER", "WORLD_MAP_SEVEN_ISLAND_TANOBY_RUINS_MONEAN_CHAMBER" }, + [2240] = { "FLAG_WORLD_MAP_THREE_ISLAND_DUNSPARCE_TUNNEL", "WORLD_MAP_THREE_ISLAND_DUNSPARCE_TUNNEL" }, + [2241] = { "FLAG_WORLD_MAP_SEVEN_ISLAND_SEVAULT_CANYON_TANOBY_KEY", "WORLD_MAP_SEVEN_ISLAND_SEVAULT_CANYON_TANOBY_KEY" }, + [2242] = { "FLAG_WORLD_MAP_BIRTH_ISLAND_EXTERIOR", "WORLD_MAP_BIRTH_ISLAND_EXTERIOR" }, + [2243] = { "FLAG_0x8C3", "0x8C3" }, + [2244] = { "FLAG_0x8C4", "0x8C4" }, + [2245] = { "FLAG_0x8C5", "0x8C5" }, + [2246] = { "FLAG_0x8C6", "0x8C6" }, + [2247] = { "FLAG_0x8C7", "0x8C7" }, + [2248] = { "FLAG_0x8C8", "0x8C8" }, + [2249] = { "FLAG_0x8C9", "0x8C9" }, + [2250] = { "FLAG_0x8CA", "0x8CA" }, + [2251] = { "FLAG_0x8CB", "0x8CB" }, + [2252] = { "FLAG_0x8CC", "0x8CC" }, + [2253] = { "FLAG_0x8CD", "0x8CD" }, + [2254] = { "FLAG_0x8CE", "0x8CE" }, + [2255] = { "FLAG_0x8CF", "0x8CF" }, + [2256] = { "FLAG_0x8D0", "0x8D0" }, + [2257] = { "FLAG_0x8D1", "0x8D1" }, + [2258] = { "FLAG_0x8D2", "0x8D2" }, + [2259] = { "FLAG_0x8D3", "0x8D3" }, + [2260] = { "FLAG_0x8D4", "0x8D4" }, + [2261] = { "FLAG_0x8D5", "0x8D5" }, + [2262] = { "FLAG_0x8D6", "0x8D6" }, + [2263] = { "FLAG_0x8D7", "0x8D7" }, + [2264] = { "FLAG_0x8D8", "0x8D8" }, + [2265] = { "FLAG_0x8D9", "0x8D9" }, + [2266] = { "FLAG_0x8DA", "0x8DA" }, + [2267] = { "FLAG_0x8DB", "0x8DB" }, + [2268] = { "FLAG_0x8DC", "0x8DC" }, + [2269] = { "FLAG_0x8DD", "0x8DD" }, + [2270] = { "FLAG_0x8DE", "0x8DE" }, + [2271] = { "FLAG_0x8DF", "0x8DF" }, + [2272] = { "FLAG_0x8E0", "0x8E0" }, + [2273] = { "FLAG_0x8E1", "0x8E1" }, + [2274] = { "FLAG_0x8E2", "0x8E2" }, + [2275] = { "FLAG_0x8E3", "0x8E3" }, + [2276] = { "FLAG_0x8E4", "0x8E4" }, + [2277] = { "FLAG_0x8E5", "0x8E5" }, + [2278] = { "FLAG_0x8E6", "0x8E6" }, + [2279] = { "FLAG_0x8E7", "0x8E7" }, + [2280] = { "FLAG_0x8E8", "0x8E8" }, + [2281] = { "FLAG_0x8E9", "0x8E9" }, + [2282] = { "FLAG_0x8EA", "0x8EA" }, + [2283] = { "FLAG_0x8EB", "0x8EB" }, + [2284] = { "FLAG_0x8EC", "0x8EC" }, + [2285] = { "FLAG_0x8ED", "0x8ED" }, + [2286] = { "FLAG_0x8EE", "0x8EE" }, + [2287] = { "FLAG_0x8EF", "0x8EF" }, + [2288] = { "FLAG_0x8F0", "0x8F0" }, + [2289] = { "FLAG_0x8F1", "0x8F1" }, + [2290] = { "FLAG_0x8F2", "0x8F2" }, + [2291] = { "FLAG_0x8F3", "0x8F3" }, + [2292] = { "FLAG_0x8F4", "0x8F4" }, + [2293] = { "FLAG_0x8F5", "0x8F5" }, + [2294] = { "FLAG_0x8F6", "0x8F6" }, + [2295] = { "FLAG_0x8F7", "0x8F7" }, + [2296] = { "FLAG_0x8F8", "0x8F8" }, + [2297] = { "FLAG_0x8F9", "0x8F9" }, + [2298] = { "FLAG_0x8FA", "0x8FA" }, + [2299] = { "FLAG_0x8FB", "0x8FB" }, + [2300] = { "FLAG_0x8FC", "0x8FC" }, + [2301] = { "FLAG_0x8FD", "0x8FD" }, + [2302] = { "FLAG_0x8FE", "0x8FE" }, + [2303] = { "FLAG_0x8FF", "0x8FF" }, + }, +} diff --git a/src/mods/flag_names/red.lua b/src/mods/flag_names/red.lua new file mode 100644 index 000000000..14899c835 --- /dev/null +++ b/src/mods/flag_names/red.lua @@ -0,0 +1,514 @@ +-- Generated flag identifiers only; no engine implementation. +-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 +-- Regenerate with tools/flag-catalog-check/generate.py. +return { + records = { + [0] = { "EVENT_FOLLOWED_OAK_INTO_LAB" }, + [3] = { "EVENT_HALL_OF_FAME_DEX_RATING" }, + [6] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS" }, + [24] = { "EVENT_GOT_TOWN_MAP" }, + [25] = { "EVENT_ENTERED_BLUES_HOUSE" }, + [26] = { "EVENT_DAISY_WALKING" }, + [32] = { "EVENT_FOLLOWED_OAK_INTO_LAB_2" }, + [33] = { "EVENT_OAK_ASKED_TO_CHOOSE_MON" }, + [34] = { "EVENT_GOT_STARTER" }, + [35] = { "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, + [36] = { "EVENT_GOT_POKEBALLS_FROM_OAK" }, + [37] = { "EVENT_GOT_POKEDEX" }, + [38] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS_2" }, + [39] = { "EVENT_OAK_APPEARED_IN_PALLET" }, + [40] = { "EVENT_VIRIDIAN_GYM_OPEN" }, + [41] = { "EVENT_GOT_TM42" }, + [56] = { "EVENT_OAK_GOT_PARCEL" }, + [57] = { "EVENT_GOT_OAKS_PARCEL" }, + [80] = { "EVENT_GOT_TM27" }, + [81] = { "EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI" }, + [82] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0" }, + [83] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_1" }, + [84] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_2" }, + [85] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_3" }, + [86] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_4" }, + [87] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_5" }, + [88] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_6" }, + [89] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_7" }, + [104] = { "EVENT_BOUGHT_MUSEUM_TICKET" }, + [105] = { "EVENT_GOT_OLD_AMBER" }, + [114] = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" }, + [118] = { "EVENT_GOT_TM34" }, + [119] = { "EVENT_BEAT_BROCK" }, + [152] = { "EVENT_BEAT_CERULEAN_RIVAL" }, + [167] = { "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, + [186] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_0" }, + [187] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_1" }, + [190] = { "EVENT_GOT_TM11" }, + [191] = { "EVENT_BEAT_MISTY" }, + [192] = { "EVENT_GOT_BICYCLE" }, + [238] = { "EVENT_POKEMON_TOWER_RIVAL_ON_LEFT" }, + [239] = { "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, + [241] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_0" }, + [242] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_1" }, + [243] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_2" }, + [249] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_0" }, + [250] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_1" }, + [251] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_2" }, + [258] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_0" }, + [259] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_1" }, + [260] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_2" }, + [261] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_3" }, + [263] = { "EVENT_IN_PURIFIED_ZONE" }, + [265] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_0" }, + [266] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_1" }, + [267] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_2" }, + [271] = { "EVENT_BEAT_GHOST_MAROWAK" }, + [273] = { "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0" }, + [274] = { "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1" }, + [275] = { "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2" }, + [279] = { "EVENT_RESCUED_MR_FUJI_2" }, + [296] = { "EVENT_GOT_POKE_FLUTE" }, + [337] = { "EVENT_GOT_BIKE_VOUCHER" }, + [342] = { "EVENT_SEEL_FAN_BOAST" }, + [343] = { "EVENT_PIKACHU_FAN_BOAST" }, + [352] = { "EVENT_2ND_LOCK_OPENED" }, + [353] = { "EVENT_1ST_LOCK_OPENED" }, + [354] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_0" }, + [355] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_1" }, + [356] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_2" }, + [358] = { "EVENT_GOT_TM24" }, + [359] = { "EVENT_BEAT_LT_SURGE" }, + [384] = { "EVENT_GOT_TM41" }, + [396] = { "EVENT_GOT_TM13" }, + [397] = { "EVENT_GOT_TM48" }, + [398] = { "EVENT_GOT_TM49" }, + [399] = { "EVENT_GOT_TM18" }, + [424] = { "EVENT_GOT_TM21" }, + [425] = { "EVENT_BEAT_ERIKA" }, + [426] = { "EVENT_BEAT_CELADON_GYM_TRAINER_0" }, + [427] = { "EVENT_BEAT_CELADON_GYM_TRAINER_1" }, + [428] = { "EVENT_BEAT_CELADON_GYM_TRAINER_2" }, + [429] = { "EVENT_BEAT_CELADON_GYM_TRAINER_3" }, + [430] = { "EVENT_BEAT_CELADON_GYM_TRAINER_4" }, + [431] = { "EVENT_BEAT_CELADON_GYM_TRAINER_5" }, + [432] = { "EVENT_BEAT_CELADON_GYM_TRAINER_6" }, + [440] = { "EVENT_1B8" }, + [441] = { "EVENT_FOUND_ROCKET_HIDEOUT" }, + [442] = { "EVENT_GOT_10_COINS" }, + [443] = { "EVENT_GOT_20_COINS" }, + [444] = { "EVENT_GOT_20_COINS_2" }, + [447] = { "EVENT_1BF" }, + [480] = { "EVENT_GOT_COIN_CASE" }, + [568] = { "EVENT_GOT_HM04" }, + [569] = { "EVENT_GAVE_GOLD_TEETH" }, + [590] = { "EVENT_SAFARI_GAME_OVER" }, + [591] = { "EVENT_IN_SAFARI_ZONE" }, + [600] = { "EVENT_GOT_TM06" }, + [601] = { "EVENT_BEAT_KOGA" }, + [602] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_0" }, + [603] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_1" }, + [604] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_2" }, + [605] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_3" }, + [606] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_4" }, + [607] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_5" }, + [632] = { "EVENT_MANSION_SWITCH_ON" }, + [649] = { "EVENT_BEAT_MANSION_1_TRAINER_0" }, + [664] = { "EVENT_GOT_TM38" }, + [665] = { "EVENT_BEAT_BLAINE" }, + [666] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_0" }, + [667] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_1" }, + [668] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_2" }, + [669] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_3" }, + [670] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_4" }, + [671] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_5" }, + [672] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_6" }, + [679] = { "EVENT_2A7" }, + [680] = { "EVENT_CINNABAR_GYM_GATE0_UNLOCKED" }, + [681] = { "EVENT_CINNABAR_GYM_GATE1_UNLOCKED" }, + [682] = { "EVENT_CINNABAR_GYM_GATE2_UNLOCKED" }, + [683] = { "EVENT_CINNABAR_GYM_GATE3_UNLOCKED" }, + [684] = { "EVENT_CINNABAR_GYM_GATE4_UNLOCKED" }, + [685] = { "EVENT_CINNABAR_GYM_GATE5_UNLOCKED" }, + [686] = { "EVENT_CINNABAR_GYM_GATE6_UNLOCKED" }, + [727] = { "EVENT_GOT_TM35" }, + [736] = { "EVENT_GAVE_FOSSIL_TO_LAB" }, + [737] = { "EVENT_LAB_STILL_REVIVING_FOSSIL" }, + [738] = { "EVENT_LAB_HANDING_OVER_FOSSIL_MON" }, + [832] = { "EVENT_GOT_TM31" }, + [848] = { "EVENT_DEFEATED_FIGHTING_DOJO" }, + [849] = { "EVENT_BEAT_KARATE_MASTER" }, + [850] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_0" }, + [851] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_1" }, + [852] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_2" }, + [853] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_3" }, + [854] = { "EVENT_GOT_HITMONLEE" }, + [855] = { "EVENT_GOT_HITMONCHAN" }, + [864] = { "EVENT_GOT_TM46" }, + [865] = { "EVENT_BEAT_SABRINA" }, + [866] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_0" }, + [867] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_1" }, + [868] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_2" }, + [869] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_3" }, + [870] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_4" }, + [871] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_5" }, + [872] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_6" }, + [919] = { "EVENT_SILPH_CO_RECEPTIONIST_AT_DESK" }, + [944] = { "EVENT_GOT_TM29" }, + [960] = { "EVENT_GOT_POTION_SAMPLE" }, + [984] = { "EVENT_GOT_HM05" }, + [994] = { "EVENT_BEAT_ROUTE_3_TRAINER_0" }, + [995] = { "EVENT_BEAT_ROUTE_3_TRAINER_1" }, + [996] = { "EVENT_BEAT_ROUTE_3_TRAINER_2" }, + [997] = { "EVENT_BEAT_ROUTE_3_TRAINER_3" }, + [998] = { "EVENT_BEAT_ROUTE_3_TRAINER_4" }, + [999] = { "EVENT_BEAT_ROUTE_3_TRAINER_5" }, + [1000] = { "EVENT_BEAT_ROUTE_3_TRAINER_6" }, + [1001] = { "EVENT_BEAT_ROUTE_3_TRAINER_7" }, + [1010] = { "EVENT_BEAT_ROUTE_4_TRAINER_0" }, + [1023] = { "EVENT_BOUGHT_MAGIKARP" }, + [1041] = { "EVENT_BEAT_ROUTE_6_TRAINER_0" }, + [1042] = { "EVENT_BEAT_ROUTE_6_TRAINER_1" }, + [1043] = { "EVENT_BEAT_ROUTE_6_TRAINER_2" }, + [1044] = { "EVENT_BEAT_ROUTE_6_TRAINER_3" }, + [1045] = { "EVENT_BEAT_ROUTE_6_TRAINER_4" }, + [1046] = { "EVENT_BEAT_ROUTE_6_TRAINER_5" }, + [1073] = { "EVENT_BEAT_ROUTE_8_TRAINER_0" }, + [1074] = { "EVENT_BEAT_ROUTE_8_TRAINER_1" }, + [1075] = { "EVENT_BEAT_ROUTE_8_TRAINER_2" }, + [1076] = { "EVENT_BEAT_ROUTE_8_TRAINER_3" }, + [1077] = { "EVENT_BEAT_ROUTE_8_TRAINER_4" }, + [1078] = { "EVENT_BEAT_ROUTE_8_TRAINER_5" }, + [1079] = { "EVENT_BEAT_ROUTE_8_TRAINER_6" }, + [1080] = { "EVENT_BEAT_ROUTE_8_TRAINER_7" }, + [1081] = { "EVENT_BEAT_ROUTE_8_TRAINER_8" }, + [1089] = { "EVENT_BEAT_ROUTE_9_TRAINER_0" }, + [1090] = { "EVENT_BEAT_ROUTE_9_TRAINER_1" }, + [1091] = { "EVENT_BEAT_ROUTE_9_TRAINER_2" }, + [1092] = { "EVENT_BEAT_ROUTE_9_TRAINER_3" }, + [1093] = { "EVENT_BEAT_ROUTE_9_TRAINER_4" }, + [1094] = { "EVENT_BEAT_ROUTE_9_TRAINER_5" }, + [1095] = { "EVENT_BEAT_ROUTE_9_TRAINER_6" }, + [1096] = { "EVENT_BEAT_ROUTE_9_TRAINER_7" }, + [1097] = { "EVENT_BEAT_ROUTE_9_TRAINER_8" }, + [1105] = { "EVENT_BEAT_ROUTE_10_TRAINER_0" }, + [1106] = { "EVENT_BEAT_ROUTE_10_TRAINER_1" }, + [1107] = { "EVENT_BEAT_ROUTE_10_TRAINER_2" }, + [1108] = { "EVENT_BEAT_ROUTE_10_TRAINER_3" }, + [1109] = { "EVENT_BEAT_ROUTE_10_TRAINER_4" }, + [1110] = { "EVENT_BEAT_ROUTE_10_TRAINER_5" }, + [1113] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0" }, + [1114] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1" }, + [1115] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2" }, + [1116] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3" }, + [1117] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4" }, + [1118] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5" }, + [1119] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6" }, + [1121] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_0" }, + [1122] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_1" }, + [1123] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_2" }, + [1124] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_3" }, + [1125] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_4" }, + [1126] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_5" }, + [1127] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_6" }, + [1128] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_7" }, + [1129] = { "EVENT_BEAT_ZAPDOS" }, + [1137] = { "EVENT_BEAT_ROUTE_11_TRAINER_0" }, + [1138] = { "EVENT_BEAT_ROUTE_11_TRAINER_1" }, + [1139] = { "EVENT_BEAT_ROUTE_11_TRAINER_2" }, + [1140] = { "EVENT_BEAT_ROUTE_11_TRAINER_3" }, + [1141] = { "EVENT_BEAT_ROUTE_11_TRAINER_4" }, + [1142] = { "EVENT_BEAT_ROUTE_11_TRAINER_5" }, + [1143] = { "EVENT_BEAT_ROUTE_11_TRAINER_6" }, + [1144] = { "EVENT_BEAT_ROUTE_11_TRAINER_7" }, + [1145] = { "EVENT_BEAT_ROUTE_11_TRAINER_8" }, + [1146] = { "EVENT_BEAT_ROUTE_11_TRAINER_9" }, + [1151] = { "EVENT_GOT_ITEMFINDER" }, + [1152] = { "EVENT_GOT_TM39" }, + [1154] = { "EVENT_BEAT_ROUTE_12_TRAINER_0" }, + [1155] = { "EVENT_BEAT_ROUTE_12_TRAINER_1" }, + [1156] = { "EVENT_BEAT_ROUTE_12_TRAINER_2" }, + [1157] = { "EVENT_BEAT_ROUTE_12_TRAINER_3" }, + [1158] = { "EVENT_BEAT_ROUTE_12_TRAINER_4" }, + [1159] = { "EVENT_BEAT_ROUTE_12_TRAINER_5" }, + [1160] = { "EVENT_BEAT_ROUTE_12_TRAINER_6" }, + [1166] = { "EVENT_FIGHT_ROUTE12_SNORLAX" }, + [1167] = { "EVENT_BEAT_ROUTE12_SNORLAX" }, + [1169] = { "EVENT_BEAT_ROUTE_13_TRAINER_0" }, + [1170] = { "EVENT_BEAT_ROUTE_13_TRAINER_1" }, + [1171] = { "EVENT_BEAT_ROUTE_13_TRAINER_2" }, + [1172] = { "EVENT_BEAT_ROUTE_13_TRAINER_3" }, + [1173] = { "EVENT_BEAT_ROUTE_13_TRAINER_4" }, + [1174] = { "EVENT_BEAT_ROUTE_13_TRAINER_5" }, + [1175] = { "EVENT_BEAT_ROUTE_13_TRAINER_6" }, + [1176] = { "EVENT_BEAT_ROUTE_13_TRAINER_7" }, + [1177] = { "EVENT_BEAT_ROUTE_13_TRAINER_8" }, + [1178] = { "EVENT_BEAT_ROUTE_13_TRAINER_9" }, + [1185] = { "EVENT_BEAT_ROUTE_14_TRAINER_0" }, + [1186] = { "EVENT_BEAT_ROUTE_14_TRAINER_1" }, + [1187] = { "EVENT_BEAT_ROUTE_14_TRAINER_2" }, + [1188] = { "EVENT_BEAT_ROUTE_14_TRAINER_3" }, + [1189] = { "EVENT_BEAT_ROUTE_14_TRAINER_4" }, + [1190] = { "EVENT_BEAT_ROUTE_14_TRAINER_5" }, + [1191] = { "EVENT_BEAT_ROUTE_14_TRAINER_6" }, + [1192] = { "EVENT_BEAT_ROUTE_14_TRAINER_7" }, + [1193] = { "EVENT_BEAT_ROUTE_14_TRAINER_8" }, + [1194] = { "EVENT_BEAT_ROUTE_14_TRAINER_9" }, + [1200] = { "EVENT_GOT_EXP_ALL" }, + [1201] = { "EVENT_BEAT_ROUTE_15_TRAINER_0" }, + [1202] = { "EVENT_BEAT_ROUTE_15_TRAINER_1" }, + [1203] = { "EVENT_BEAT_ROUTE_15_TRAINER_2" }, + [1204] = { "EVENT_BEAT_ROUTE_15_TRAINER_3" }, + [1205] = { "EVENT_BEAT_ROUTE_15_TRAINER_4" }, + [1206] = { "EVENT_BEAT_ROUTE_15_TRAINER_5" }, + [1207] = { "EVENT_BEAT_ROUTE_15_TRAINER_6" }, + [1208] = { "EVENT_BEAT_ROUTE_15_TRAINER_7" }, + [1209] = { "EVENT_BEAT_ROUTE_15_TRAINER_8" }, + [1210] = { "EVENT_BEAT_ROUTE_15_TRAINER_9" }, + [1217] = { "EVENT_BEAT_ROUTE_16_TRAINER_0" }, + [1218] = { "EVENT_BEAT_ROUTE_16_TRAINER_1" }, + [1219] = { "EVENT_BEAT_ROUTE_16_TRAINER_2" }, + [1220] = { "EVENT_BEAT_ROUTE_16_TRAINER_3" }, + [1221] = { "EVENT_BEAT_ROUTE_16_TRAINER_4" }, + [1222] = { "EVENT_BEAT_ROUTE_16_TRAINER_5" }, + [1224] = { "EVENT_FIGHT_ROUTE16_SNORLAX" }, + [1225] = { "EVENT_BEAT_ROUTE16_SNORLAX" }, + [1230] = { "EVENT_GOT_HM02" }, + [1231] = { "EVENT_RESCUED_MR_FUJI" }, + [1233] = { "EVENT_BEAT_ROUTE_17_TRAINER_0" }, + [1234] = { "EVENT_BEAT_ROUTE_17_TRAINER_1" }, + [1235] = { "EVENT_BEAT_ROUTE_17_TRAINER_2" }, + [1236] = { "EVENT_BEAT_ROUTE_17_TRAINER_3" }, + [1237] = { "EVENT_BEAT_ROUTE_17_TRAINER_4" }, + [1238] = { "EVENT_BEAT_ROUTE_17_TRAINER_5" }, + [1239] = { "EVENT_BEAT_ROUTE_17_TRAINER_6" }, + [1240] = { "EVENT_BEAT_ROUTE_17_TRAINER_7" }, + [1241] = { "EVENT_BEAT_ROUTE_17_TRAINER_8" }, + [1242] = { "EVENT_BEAT_ROUTE_17_TRAINER_9" }, + [1249] = { "EVENT_BEAT_ROUTE_18_TRAINER_0" }, + [1250] = { "EVENT_BEAT_ROUTE_18_TRAINER_1" }, + [1251] = { "EVENT_BEAT_ROUTE_18_TRAINER_2" }, + [1265] = { "EVENT_BEAT_ROUTE_19_TRAINER_0" }, + [1266] = { "EVENT_BEAT_ROUTE_19_TRAINER_1" }, + [1267] = { "EVENT_BEAT_ROUTE_19_TRAINER_2" }, + [1268] = { "EVENT_BEAT_ROUTE_19_TRAINER_3" }, + [1269] = { "EVENT_BEAT_ROUTE_19_TRAINER_4" }, + [1270] = { "EVENT_BEAT_ROUTE_19_TRAINER_5" }, + [1271] = { "EVENT_BEAT_ROUTE_19_TRAINER_6" }, + [1272] = { "EVENT_BEAT_ROUTE_19_TRAINER_7" }, + [1273] = { "EVENT_BEAT_ROUTE_19_TRAINER_8" }, + [1274] = { "EVENT_BEAT_ROUTE_19_TRAINER_9" }, + [1280] = { "EVENT_IN_SEAFOAM_ISLANDS" }, + [1281] = { "EVENT_BEAT_ROUTE_20_TRAINER_0" }, + [1282] = { "EVENT_BEAT_ROUTE_20_TRAINER_1" }, + [1283] = { "EVENT_BEAT_ROUTE_20_TRAINER_2" }, + [1284] = { "EVENT_BEAT_ROUTE_20_TRAINER_3" }, + [1285] = { "EVENT_BEAT_ROUTE_20_TRAINER_4" }, + [1286] = { "EVENT_BEAT_ROUTE_20_TRAINER_5" }, + [1287] = { "EVENT_BEAT_ROUTE_20_TRAINER_6" }, + [1288] = { "EVENT_BEAT_ROUTE_20_TRAINER_7" }, + [1289] = { "EVENT_BEAT_ROUTE_20_TRAINER_8" }, + [1290] = { "EVENT_BEAT_ROUTE_20_TRAINER_9" }, + [1294] = { "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE" }, + [1295] = { "EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE" }, + [1297] = { "EVENT_BEAT_ROUTE_21_TRAINER_0" }, + [1298] = { "EVENT_BEAT_ROUTE_21_TRAINER_1" }, + [1299] = { "EVENT_BEAT_ROUTE_21_TRAINER_2" }, + [1300] = { "EVENT_BEAT_ROUTE_21_TRAINER_3" }, + [1301] = { "EVENT_BEAT_ROUTE_21_TRAINER_4" }, + [1302] = { "EVENT_BEAT_ROUTE_21_TRAINER_5" }, + [1303] = { "EVENT_BEAT_ROUTE_21_TRAINER_6" }, + [1304] = { "EVENT_BEAT_ROUTE_21_TRAINER_7" }, + [1305] = { "EVENT_BEAT_ROUTE_21_TRAINER_8" }, + [1312] = { "EVENT_1ST_ROUTE22_RIVAL_BATTLE" }, + [1313] = { "EVENT_2ND_ROUTE22_RIVAL_BATTLE" }, + [1317] = { "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, + [1318] = { "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE" }, + [1319] = { "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" }, + [1328] = { "EVENT_PASSED_CASCADEBADGE_CHECK" }, + [1329] = { "EVENT_PASSED_THUNDERBADGE_CHECK" }, + [1330] = { "EVENT_PASSED_RAINBOWBADGE_CHECK" }, + [1331] = { "EVENT_PASSED_SOULBADGE_CHECK" }, + [1332] = { "EVENT_PASSED_MARSHBADGE_CHECK" }, + [1333] = { "EVENT_PASSED_VOLCANOBADGE_CHECK" }, + [1334] = { "EVENT_PASSED_EARTHBADGE_CHECK" }, + [1336] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1" }, + [1337] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_0" }, + [1338] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_1" }, + [1339] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_2" }, + [1340] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_3" }, + [1341] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_4" }, + [1342] = { "EVENT_BEAT_MOLTRES" }, + [1343] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2" }, + [1344] = { "EVENT_GOT_NUGGET" }, + [1345] = { "EVENT_BEAT_ROUTE24_ROCKET" }, + [1346] = { "EVENT_BEAT_ROUTE_24_TRAINER_0" }, + [1347] = { "EVENT_BEAT_ROUTE_24_TRAINER_1" }, + [1348] = { "EVENT_BEAT_ROUTE_24_TRAINER_2" }, + [1349] = { "EVENT_BEAT_ROUTE_24_TRAINER_3" }, + [1350] = { "EVENT_BEAT_ROUTE_24_TRAINER_4" }, + [1351] = { "EVENT_BEAT_ROUTE_24_TRAINER_5" }, + [1353] = { "EVENT_NUGGET_REWARD_AVAILABLE" }, + [1360] = { "EVENT_MET_BILL" }, + [1361] = { "EVENT_BEAT_ROUTE_25_TRAINER_0" }, + [1362] = { "EVENT_BEAT_ROUTE_25_TRAINER_1" }, + [1363] = { "EVENT_BEAT_ROUTE_25_TRAINER_2" }, + [1364] = { "EVENT_BEAT_ROUTE_25_TRAINER_3" }, + [1365] = { "EVENT_BEAT_ROUTE_25_TRAINER_4" }, + [1366] = { "EVENT_BEAT_ROUTE_25_TRAINER_5" }, + [1367] = { "EVENT_BEAT_ROUTE_25_TRAINER_6" }, + [1368] = { "EVENT_BEAT_ROUTE_25_TRAINER_7" }, + [1369] = { "EVENT_BEAT_ROUTE_25_TRAINER_8" }, + [1371] = { "EVENT_USED_CELL_SEPARATOR_ON_BILL" }, + [1372] = { "EVENT_GOT_SS_TICKET" }, + [1373] = { "EVENT_MET_BILL_2" }, + [1374] = { "EVENT_BILL_SAID_USE_CELL_SEPARATOR" }, + [1375] = { "EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING" }, + [1378] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_0" }, + [1379] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_1" }, + [1380] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2" }, + [1393] = { "EVENT_BEAT_MT_MOON_1_TRAINER_0" }, + [1394] = { "EVENT_BEAT_MT_MOON_1_TRAINER_1" }, + [1395] = { "EVENT_BEAT_MT_MOON_1_TRAINER_2" }, + [1396] = { "EVENT_BEAT_MT_MOON_1_TRAINER_3" }, + [1397] = { "EVENT_BEAT_MT_MOON_1_TRAINER_4" }, + [1398] = { "EVENT_BEAT_MT_MOON_1_TRAINER_5" }, + [1399] = { "EVENT_BEAT_MT_MOON_1_TRAINER_6" }, + [1401] = { "EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD" }, + [1402] = { "EVENT_BEAT_MT_MOON_3_TRAINER_0" }, + [1403] = { "EVENT_BEAT_MT_MOON_3_TRAINER_1" }, + [1404] = { "EVENT_BEAT_MT_MOON_3_TRAINER_2" }, + [1405] = { "EVENT_BEAT_MT_MOON_3_TRAINER_3" }, + [1406] = { "EVENT_GOT_DOME_FOSSIL" }, + [1407] = { "EVENT_GOT_HELIX_FOSSIL" }, + [1476] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_0" }, + [1477] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_1" }, + [1504] = { "EVENT_GOT_HM01" }, + [1505] = { "EVENT_RUBBED_CAPTAINS_BACK" }, + [1506] = { "EVENT_SS_ANNE_LEFT" }, + [1507] = { "EVENT_WALKED_PAST_GUARD_AFTER_SS_ANNE_LEFT" }, + [1508] = { "EVENT_STARTED_WALKING_OUT_OF_DOCK" }, + [1509] = { "EVENT_WALKED_OUT_OF_DOCK" }, + [1521] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_0" }, + [1522] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_1" }, + [1523] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_2" }, + [1524] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_3" }, + [1537] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_0" }, + [1538] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_1" }, + [1539] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_2" }, + [1540] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_3" }, + [1553] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_0" }, + [1554] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_1" }, + [1555] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_2" }, + [1556] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_3" }, + [1557] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_4" }, + [1558] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_5" }, + [1632] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1" }, + [1633] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0" }, + [1634] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1" }, + [1635] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2" }, + [1636] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3" }, + [1638] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2" }, + [1649] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0" }, + [1650] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1" }, + [1651] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2" }, + [1652] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3" }, + [1653] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" }, + [1655] = { "EVENT_ENTERED_ROCKET_HIDEOUT" }, + [1663] = { "EVENT_67F" }, + [1665] = { "EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0" }, + [1681] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0" }, + [1682] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1" }, + [1698] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0" }, + [1699] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" }, + [1700] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2" }, + [1701] = { "EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED" }, + [1702] = { "EVENT_ROCKET_DROPPED_LIFT_KEY" }, + [1703] = { "EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI" }, + [1778] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_0" }, + [1779] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_1" }, + [1780] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_2" }, + [1781] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_3" }, + [1789] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR1" }, + [1790] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR2" }, + [1791] = { "EVENT_GOT_TM36" }, + [1794] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_0" }, + [1795] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_1" }, + [1800] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR1" }, + [1801] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR2" }, + [1810] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_0" }, + [1811] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_1" }, + [1812] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_2" }, + [1816] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR1" }, + [1817] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR2" }, + [1826] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_0" }, + [1827] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_1" }, + [1828] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_2" }, + [1829] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_3" }, + [1832] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR1" }, + [1833] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR2" }, + [1834] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR3" }, + [1846] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_0" }, + [1847] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_1" }, + [1848] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_2" }, + [1855] = { "EVENT_SILPH_CO_6_UNLOCKED_DOOR" }, + [1856] = { "EVENT_BEAT_SILPH_CO_RIVAL" }, + [1861] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_0" }, + [1862] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_1" }, + [1863] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_2" }, + [1864] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_3" }, + [1868] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR1" }, + [1869] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR2" }, + [1870] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR3" }, + [1874] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_0" }, + [1875] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_1" }, + [1876] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_2" }, + [1880] = { "EVENT_SILPH_CO_8_UNLOCKED_DOOR" }, + [1890] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_0" }, + [1891] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_1" }, + [1892] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_2" }, + [1896] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR1" }, + [1897] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR2" }, + [1898] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR3" }, + [1899] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR4" }, + [1905] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_0" }, + [1906] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_1" }, + [1912] = { "EVENT_SILPH_CO_10_UNLOCKED_DOOR" }, + [1924] = { "EVENT_BEAT_SILPH_CO_11F_TRAINER_0" }, + [1925] = { "EVENT_BEAT_SILPH_CO_11F_TRAINER_1" }, + [1928] = { "EVENT_SILPH_CO_11_UNLOCKED_DOOR" }, + [1933] = { "EVENT_GOT_MASTER_BALL" }, + [1935] = { "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + [2049] = { "EVENT_BEAT_MANSION_2_TRAINER_0" }, + [2065] = { "EVENT_BEAT_MANSION_3_TRAINER_0" }, + [2066] = { "EVENT_BEAT_MANSION_3_TRAINER_1" }, + [2081] = { "EVENT_BEAT_MANSION_4_TRAINER_0" }, + [2082] = { "EVENT_BEAT_MANSION_4_TRAINER_1" }, + [2176] = { "EVENT_GOT_HM03" }, + [2241] = { "EVENT_BEAT_MEWTWO" }, + [2273] = { "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0" }, + [2278] = { "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM" }, + [2281] = { "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0" }, + [2286] = { "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM" }, + [2289] = { "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0" }, + [2294] = { "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM" }, + [2297] = { "EVENT_BEAT_LANCES_ROOM_TRAINER_0" }, + [2302] = { "EVENT_BEAT_LANCE" }, + [2303] = { "EVENT_LANCES_ROOM_LOCK_DOOR" }, + [2305] = { "EVENT_BEAT_CHAMPION_RIVAL" }, + [2321] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_0" }, + [2322] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_1" }, + [2327] = { "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH" }, + [2481] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0" }, + [2482] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1" }, + [2483] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2" }, + [2484] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3" }, + [2485] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4" }, + [2486] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5" }, + [2487] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6" }, + [2488] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7" }, + [2496] = { "EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE" }, + [2497] = { "EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE" }, + [2504] = { "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE" }, + [2505] = { "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE" }, + [2512] = { "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE" }, + [2513] = { "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE" }, + [2522] = { "EVENT_BEAT_ARTICUNO" }, + }, +} diff --git a/src/mods/flag_names/yellow.lua b/src/mods/flag_names/yellow.lua new file mode 100644 index 000000000..21cbb4acd --- /dev/null +++ b/src/mods/flag_names/yellow.lua @@ -0,0 +1,529 @@ +-- Generated flag identifiers only; no engine implementation. +-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 +-- Regenerate with tools/flag-catalog-check/generate.py. +return { + records = { + [0] = { "EVENT_FOLLOWED_OAK_INTO_LAB" }, + [3] = { "EVENT_HALL_OF_FAME_DEX_RATING" }, + [5] = { "EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN" }, + [6] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS" }, + [24] = { "EVENT_GOT_TOWN_MAP" }, + [25] = { "EVENT_ENTERED_BLUES_HOUSE" }, + [26] = { "EVENT_DAISY_WALKING" }, + [32] = { "EVENT_FOLLOWED_OAK_INTO_LAB_2" }, + [33] = { "EVENT_OAK_ASKED_TO_CHOOSE_MON" }, + [34] = { "EVENT_GOT_STARTER" }, + [35] = { "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, + [36] = { "EVENT_GOT_POKEBALLS_FROM_OAK" }, + [37] = { "EVENT_GOT_POKEDEX" }, + [38] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS_2" }, + [39] = { "EVENT_OAK_APPEARED_IN_PALLET" }, + [40] = { "EVENT_VIRIDIAN_GYM_OPEN" }, + [41] = { "EVENT_GOT_TM42" }, + [44] = { "EVENT_SPAWNED_OLD_MAN_1" }, + [45] = { "EVENT_COMPLETED_CATCH_TRAINING" }, + [46] = { "EVENT_COMPLETED_CATCH_TRAINING_AGAIN" }, + [47] = { "EVENT_INITIAL_CATCH_TRAINING" }, + [56] = { "EVENT_OAK_GOT_PARCEL" }, + [57] = { "EVENT_GOT_OAKS_PARCEL" }, + [80] = { "EVENT_GOT_TM27" }, + [81] = { "EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI" }, + [82] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0" }, + [83] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_1" }, + [84] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_2" }, + [85] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_3" }, + [86] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_4" }, + [87] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_5" }, + [88] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_6" }, + [89] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_7" }, + [104] = { "EVENT_BOUGHT_MUSEUM_TICKET" }, + [105] = { "EVENT_GOT_OLD_AMBER" }, + [114] = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" }, + [118] = { "EVENT_GOT_TM34" }, + [119] = { "EVENT_BEAT_BROCK" }, + [152] = { "EVENT_BEAT_CERULEAN_RIVAL" }, + [167] = { "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, + [168] = { "EVENT_GOT_BULBASAUR_IN_CERULEAN" }, + [186] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_0" }, + [187] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_1" }, + [190] = { "EVENT_GOT_TM11" }, + [191] = { "EVENT_BEAT_MISTY" }, + [192] = { "EVENT_GOT_BICYCLE" }, + [238] = { "EVENT_POKEMON_TOWER_RIVAL_ON_LEFT" }, + [239] = { "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, + [241] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_0" }, + [242] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_1" }, + [243] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_2" }, + [249] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_0" }, + [250] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_1" }, + [251] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_2" }, + [258] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_0" }, + [259] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_1" }, + [260] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_2" }, + [261] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_3" }, + [263] = { "EVENT_IN_PURIFIED_ZONE" }, + [265] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_0" }, + [266] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_1" }, + [267] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_2" }, + [271] = { "EVENT_BEAT_GHOST_MAROWAK" }, + [273] = { "EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES" }, + [274] = { "EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT" }, + [279] = { "EVENT_RESCUED_MR_FUJI_2" }, + [296] = { "EVENT_GOT_POKE_FLUTE" }, + [327] = { "EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }, + [337] = { "EVENT_GOT_BIKE_VOUCHER" }, + [338] = { "EVENT_LEFT_FANCLUB_AFTER_BIKE_VOUCHER" }, + [342] = { "EVENT_SEEL_FAN_BOAST" }, + [343] = { "EVENT_PIKACHU_FAN_BOAST" }, + [352] = { "EVENT_2ND_LOCK_OPENED" }, + [353] = { "EVENT_1ST_LOCK_OPENED" }, + [354] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_0" }, + [355] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_1" }, + [356] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_2" }, + [358] = { "EVENT_GOT_TM24" }, + [359] = { "EVENT_BEAT_LT_SURGE" }, + [384] = { "EVENT_GOT_TM41" }, + [396] = { "EVENT_GOT_TM13" }, + [397] = { "EVENT_GOT_TM48" }, + [398] = { "EVENT_GOT_TM49" }, + [399] = { "EVENT_GOT_TM18" }, + [424] = { "EVENT_GOT_TM21" }, + [425] = { "EVENT_BEAT_ERIKA" }, + [426] = { "EVENT_BEAT_CELADON_GYM_TRAINER_0" }, + [427] = { "EVENT_BEAT_CELADON_GYM_TRAINER_1" }, + [428] = { "EVENT_BEAT_CELADON_GYM_TRAINER_2" }, + [429] = { "EVENT_BEAT_CELADON_GYM_TRAINER_3" }, + [430] = { "EVENT_BEAT_CELADON_GYM_TRAINER_4" }, + [431] = { "EVENT_BEAT_CELADON_GYM_TRAINER_5" }, + [432] = { "EVENT_BEAT_CELADON_GYM_TRAINER_6" }, + [440] = { "EVENT_1B8" }, + [441] = { "EVENT_FOUND_ROCKET_HIDEOUT" }, + [442] = { "EVENT_GOT_10_COINS" }, + [443] = { "EVENT_GOT_20_COINS" }, + [444] = { "EVENT_GOT_20_COINS_2" }, + [447] = { "EVENT_1BF" }, + [480] = { "EVENT_GOT_COIN_CASE" }, + [568] = { "EVENT_GOT_HM04" }, + [569] = { "EVENT_GAVE_GOLD_TEETH" }, + [590] = { "EVENT_SAFARI_GAME_OVER" }, + [591] = { "EVENT_IN_SAFARI_ZONE" }, + [600] = { "EVENT_GOT_TM06" }, + [601] = { "EVENT_BEAT_KOGA" }, + [602] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_0" }, + [603] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_1" }, + [604] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_2" }, + [605] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_3" }, + [606] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_4" }, + [607] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_5" }, + [632] = { "EVENT_MANSION_SWITCH_ON" }, + [649] = { "EVENT_BEAT_MANSION_1_TRAINER_0" }, + [664] = { "EVENT_GOT_TM38" }, + [665] = { "EVENT_BEAT_BLAINE" }, + [666] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_0" }, + [667] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_1" }, + [668] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_2" }, + [669] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_3" }, + [670] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_4" }, + [671] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_5" }, + [672] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_6" }, + [679] = { "EVENT_2A7" }, + [680] = { "EVENT_CINNABAR_GYM_GATE0_UNLOCKED" }, + [681] = { "EVENT_CINNABAR_GYM_GATE1_UNLOCKED" }, + [682] = { "EVENT_CINNABAR_GYM_GATE2_UNLOCKED" }, + [683] = { "EVENT_CINNABAR_GYM_GATE3_UNLOCKED" }, + [684] = { "EVENT_CINNABAR_GYM_GATE4_UNLOCKED" }, + [685] = { "EVENT_CINNABAR_GYM_GATE5_UNLOCKED" }, + [686] = { "EVENT_CINNABAR_GYM_GATE6_UNLOCKED" }, + [727] = { "EVENT_GOT_TM35" }, + [736] = { "EVENT_GAVE_FOSSIL_TO_LAB" }, + [737] = { "EVENT_LAB_STILL_REVIVING_FOSSIL" }, + [738] = { "EVENT_LAB_HANDING_OVER_FOSSIL_MON" }, + [832] = { "EVENT_GOT_TM31" }, + [848] = { "EVENT_DEFEATED_FIGHTING_DOJO" }, + [849] = { "EVENT_BEAT_KARATE_MASTER" }, + [850] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_0" }, + [851] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_1" }, + [852] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_2" }, + [853] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_3" }, + [854] = { "EVENT_GOT_HITMONLEE" }, + [855] = { "EVENT_GOT_HITMONCHAN" }, + [864] = { "EVENT_GOT_TM46" }, + [865] = { "EVENT_BEAT_SABRINA" }, + [866] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_0" }, + [867] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_1" }, + [868] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_2" }, + [869] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_3" }, + [870] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_4" }, + [871] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_5" }, + [872] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_6" }, + [919] = { "EVENT_SILPH_CO_RECEPTIONIST_AT_DESK" }, + [944] = { "EVENT_GOT_TM29" }, + [960] = { "EVENT_GOT_POTION_SAMPLE" }, + [984] = { "EVENT_GOT_HM05" }, + [994] = { "EVENT_BEAT_ROUTE_3_TRAINER_0" }, + [995] = { "EVENT_BEAT_ROUTE_3_TRAINER_1" }, + [996] = { "EVENT_BEAT_ROUTE_3_TRAINER_2" }, + [997] = { "EVENT_BEAT_ROUTE_3_TRAINER_3" }, + [998] = { "EVENT_BEAT_ROUTE_3_TRAINER_4" }, + [999] = { "EVENT_BEAT_ROUTE_3_TRAINER_5" }, + [1000] = { "EVENT_BEAT_ROUTE_3_TRAINER_6" }, + [1001] = { "EVENT_BEAT_ROUTE_3_TRAINER_7" }, + [1010] = { "EVENT_BEAT_ROUTE_4_TRAINER_0" }, + [1023] = { "EVENT_BOUGHT_MAGIKARP" }, + [1041] = { "EVENT_BEAT_ROUTE_6_TRAINER_0" }, + [1042] = { "EVENT_BEAT_ROUTE_6_TRAINER_1" }, + [1043] = { "EVENT_BEAT_ROUTE_6_TRAINER_2" }, + [1044] = { "EVENT_BEAT_ROUTE_6_TRAINER_3" }, + [1045] = { "EVENT_BEAT_ROUTE_6_TRAINER_4" }, + [1046] = { "EVENT_BEAT_ROUTE_6_TRAINER_5" }, + [1073] = { "EVENT_BEAT_ROUTE_8_TRAINER_0" }, + [1074] = { "EVENT_BEAT_ROUTE_8_TRAINER_1" }, + [1075] = { "EVENT_BEAT_ROUTE_8_TRAINER_2" }, + [1076] = { "EVENT_BEAT_ROUTE_8_TRAINER_3" }, + [1077] = { "EVENT_BEAT_ROUTE_8_TRAINER_4" }, + [1078] = { "EVENT_BEAT_ROUTE_8_TRAINER_5" }, + [1079] = { "EVENT_BEAT_ROUTE_8_TRAINER_6" }, + [1080] = { "EVENT_BEAT_ROUTE_8_TRAINER_7" }, + [1081] = { "EVENT_BEAT_ROUTE_8_TRAINER_8" }, + [1089] = { "EVENT_BEAT_ROUTE_9_TRAINER_0" }, + [1090] = { "EVENT_BEAT_ROUTE_9_TRAINER_1" }, + [1091] = { "EVENT_BEAT_ROUTE_9_TRAINER_2" }, + [1092] = { "EVENT_BEAT_ROUTE_9_TRAINER_3" }, + [1093] = { "EVENT_BEAT_ROUTE_9_TRAINER_4" }, + [1094] = { "EVENT_BEAT_ROUTE_9_TRAINER_5" }, + [1095] = { "EVENT_BEAT_ROUTE_9_TRAINER_6" }, + [1096] = { "EVENT_BEAT_ROUTE_9_TRAINER_7" }, + [1097] = { "EVENT_BEAT_ROUTE_9_TRAINER_8" }, + [1105] = { "EVENT_BEAT_ROUTE_10_TRAINER_0" }, + [1106] = { "EVENT_BEAT_ROUTE_10_TRAINER_1" }, + [1107] = { "EVENT_BEAT_ROUTE_10_TRAINER_2" }, + [1108] = { "EVENT_BEAT_ROUTE_10_TRAINER_3" }, + [1109] = { "EVENT_BEAT_ROUTE_10_TRAINER_4" }, + [1110] = { "EVENT_BEAT_ROUTE_10_TRAINER_5" }, + [1113] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0" }, + [1114] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1" }, + [1115] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2" }, + [1116] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3" }, + [1117] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4" }, + [1118] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5" }, + [1119] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6" }, + [1121] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_0" }, + [1122] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_1" }, + [1123] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_2" }, + [1124] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_3" }, + [1125] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_4" }, + [1126] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_5" }, + [1127] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_6" }, + [1128] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_7" }, + [1129] = { "EVENT_BEAT_ZAPDOS" }, + [1137] = { "EVENT_BEAT_ROUTE_11_TRAINER_0" }, + [1138] = { "EVENT_BEAT_ROUTE_11_TRAINER_1" }, + [1139] = { "EVENT_BEAT_ROUTE_11_TRAINER_2" }, + [1140] = { "EVENT_BEAT_ROUTE_11_TRAINER_3" }, + [1141] = { "EVENT_BEAT_ROUTE_11_TRAINER_4" }, + [1142] = { "EVENT_BEAT_ROUTE_11_TRAINER_5" }, + [1143] = { "EVENT_BEAT_ROUTE_11_TRAINER_6" }, + [1144] = { "EVENT_BEAT_ROUTE_11_TRAINER_7" }, + [1145] = { "EVENT_BEAT_ROUTE_11_TRAINER_8" }, + [1146] = { "EVENT_BEAT_ROUTE_11_TRAINER_9" }, + [1151] = { "EVENT_GOT_ITEMFINDER" }, + [1152] = { "EVENT_GOT_TM39" }, + [1154] = { "EVENT_BEAT_ROUTE_12_TRAINER_0" }, + [1155] = { "EVENT_BEAT_ROUTE_12_TRAINER_1" }, + [1156] = { "EVENT_BEAT_ROUTE_12_TRAINER_2" }, + [1157] = { "EVENT_BEAT_ROUTE_12_TRAINER_3" }, + [1158] = { "EVENT_BEAT_ROUTE_12_TRAINER_4" }, + [1159] = { "EVENT_BEAT_ROUTE_12_TRAINER_5" }, + [1160] = { "EVENT_BEAT_ROUTE_12_TRAINER_6" }, + [1166] = { "EVENT_FIGHT_ROUTE12_SNORLAX" }, + [1167] = { "EVENT_BEAT_ROUTE12_SNORLAX" }, + [1169] = { "EVENT_BEAT_ROUTE_13_TRAINER_0" }, + [1170] = { "EVENT_BEAT_ROUTE_13_TRAINER_1" }, + [1171] = { "EVENT_BEAT_ROUTE_13_TRAINER_2" }, + [1172] = { "EVENT_BEAT_ROUTE_13_TRAINER_3" }, + [1173] = { "EVENT_BEAT_ROUTE_13_TRAINER_4" }, + [1174] = { "EVENT_BEAT_ROUTE_13_TRAINER_5" }, + [1175] = { "EVENT_BEAT_ROUTE_13_TRAINER_6" }, + [1176] = { "EVENT_BEAT_ROUTE_13_TRAINER_7" }, + [1177] = { "EVENT_BEAT_ROUTE_13_TRAINER_8" }, + [1178] = { "EVENT_BEAT_ROUTE_13_TRAINER_9" }, + [1185] = { "EVENT_BEAT_ROUTE_14_TRAINER_0" }, + [1186] = { "EVENT_BEAT_ROUTE_14_TRAINER_1" }, + [1187] = { "EVENT_BEAT_ROUTE_14_TRAINER_2" }, + [1188] = { "EVENT_BEAT_ROUTE_14_TRAINER_3" }, + [1189] = { "EVENT_BEAT_ROUTE_14_TRAINER_4" }, + [1190] = { "EVENT_BEAT_ROUTE_14_TRAINER_5" }, + [1191] = { "EVENT_BEAT_ROUTE_14_TRAINER_6" }, + [1192] = { "EVENT_BEAT_ROUTE_14_TRAINER_7" }, + [1193] = { "EVENT_BEAT_ROUTE_14_TRAINER_8" }, + [1194] = { "EVENT_BEAT_ROUTE_14_TRAINER_9" }, + [1200] = { "EVENT_GOT_EXP_ALL" }, + [1201] = { "EVENT_BEAT_ROUTE_15_TRAINER_0" }, + [1202] = { "EVENT_BEAT_ROUTE_15_TRAINER_1" }, + [1203] = { "EVENT_BEAT_ROUTE_15_TRAINER_2" }, + [1204] = { "EVENT_BEAT_ROUTE_15_TRAINER_3" }, + [1205] = { "EVENT_BEAT_ROUTE_15_TRAINER_4" }, + [1206] = { "EVENT_BEAT_ROUTE_15_TRAINER_5" }, + [1207] = { "EVENT_BEAT_ROUTE_15_TRAINER_6" }, + [1208] = { "EVENT_BEAT_ROUTE_15_TRAINER_7" }, + [1209] = { "EVENT_BEAT_ROUTE_15_TRAINER_8" }, + [1210] = { "EVENT_BEAT_ROUTE_15_TRAINER_9" }, + [1217] = { "EVENT_BEAT_ROUTE_16_TRAINER_0" }, + [1218] = { "EVENT_BEAT_ROUTE_16_TRAINER_1" }, + [1219] = { "EVENT_BEAT_ROUTE_16_TRAINER_2" }, + [1220] = { "EVENT_BEAT_ROUTE_16_TRAINER_3" }, + [1221] = { "EVENT_BEAT_ROUTE_16_TRAINER_4" }, + [1222] = { "EVENT_BEAT_ROUTE_16_TRAINER_5" }, + [1224] = { "EVENT_FIGHT_ROUTE16_SNORLAX" }, + [1225] = { "EVENT_BEAT_ROUTE16_SNORLAX" }, + [1230] = { "EVENT_GOT_HM02" }, + [1231] = { "EVENT_RESCUED_MR_FUJI" }, + [1233] = { "EVENT_BEAT_ROUTE_17_TRAINER_0" }, + [1234] = { "EVENT_BEAT_ROUTE_17_TRAINER_1" }, + [1235] = { "EVENT_BEAT_ROUTE_17_TRAINER_2" }, + [1236] = { "EVENT_BEAT_ROUTE_17_TRAINER_3" }, + [1237] = { "EVENT_BEAT_ROUTE_17_TRAINER_4" }, + [1238] = { "EVENT_BEAT_ROUTE_17_TRAINER_5" }, + [1239] = { "EVENT_BEAT_ROUTE_17_TRAINER_6" }, + [1240] = { "EVENT_BEAT_ROUTE_17_TRAINER_7" }, + [1241] = { "EVENT_BEAT_ROUTE_17_TRAINER_8" }, + [1242] = { "EVENT_BEAT_ROUTE_17_TRAINER_9" }, + [1249] = { "EVENT_BEAT_ROUTE_18_TRAINER_0" }, + [1250] = { "EVENT_BEAT_ROUTE_18_TRAINER_1" }, + [1251] = { "EVENT_BEAT_ROUTE_18_TRAINER_2" }, + [1265] = { "EVENT_BEAT_ROUTE_19_TRAINER_0" }, + [1266] = { "EVENT_BEAT_ROUTE_19_TRAINER_1" }, + [1267] = { "EVENT_BEAT_ROUTE_19_TRAINER_2" }, + [1268] = { "EVENT_BEAT_ROUTE_19_TRAINER_3" }, + [1269] = { "EVENT_BEAT_ROUTE_19_TRAINER_4" }, + [1270] = { "EVENT_BEAT_ROUTE_19_TRAINER_5" }, + [1271] = { "EVENT_BEAT_ROUTE_19_TRAINER_6" }, + [1272] = { "EVENT_BEAT_ROUTE_19_TRAINER_7" }, + [1273] = { "EVENT_BEAT_ROUTE_19_TRAINER_8" }, + [1274] = { "EVENT_BEAT_ROUTE_19_TRAINER_9" }, + [1280] = { "EVENT_IN_SEAFOAM_ISLANDS" }, + [1281] = { "EVENT_BEAT_ROUTE_20_TRAINER_0" }, + [1282] = { "EVENT_BEAT_ROUTE_20_TRAINER_1" }, + [1283] = { "EVENT_BEAT_ROUTE_20_TRAINER_2" }, + [1284] = { "EVENT_BEAT_ROUTE_20_TRAINER_3" }, + [1285] = { "EVENT_BEAT_ROUTE_20_TRAINER_4" }, + [1286] = { "EVENT_BEAT_ROUTE_20_TRAINER_5" }, + [1287] = { "EVENT_BEAT_ROUTE_20_TRAINER_6" }, + [1288] = { "EVENT_BEAT_ROUTE_20_TRAINER_7" }, + [1289] = { "EVENT_BEAT_ROUTE_20_TRAINER_8" }, + [1290] = { "EVENT_BEAT_ROUTE_20_TRAINER_9" }, + [1294] = { "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE" }, + [1295] = { "EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE" }, + [1297] = { "EVENT_BEAT_ROUTE_21_TRAINER_0" }, + [1298] = { "EVENT_BEAT_ROUTE_21_TRAINER_1" }, + [1299] = { "EVENT_BEAT_ROUTE_21_TRAINER_2" }, + [1300] = { "EVENT_BEAT_ROUTE_21_TRAINER_3" }, + [1301] = { "EVENT_BEAT_ROUTE_21_TRAINER_4" }, + [1302] = { "EVENT_BEAT_ROUTE_21_TRAINER_5" }, + [1303] = { "EVENT_BEAT_ROUTE_21_TRAINER_6" }, + [1304] = { "EVENT_BEAT_ROUTE_21_TRAINER_7" }, + [1305] = { "EVENT_BEAT_ROUTE_21_TRAINER_8" }, + [1312] = { "EVENT_1ST_ROUTE22_RIVAL_BATTLE" }, + [1313] = { "EVENT_2ND_ROUTE22_RIVAL_BATTLE" }, + [1317] = { "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, + [1318] = { "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE" }, + [1319] = { "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" }, + [1328] = { "EVENT_PASSED_CASCADEBADGE_CHECK" }, + [1329] = { "EVENT_PASSED_THUNDERBADGE_CHECK" }, + [1330] = { "EVENT_PASSED_RAINBOWBADGE_CHECK" }, + [1331] = { "EVENT_PASSED_SOULBADGE_CHECK" }, + [1332] = { "EVENT_PASSED_MARSHBADGE_CHECK" }, + [1333] = { "EVENT_PASSED_VOLCANOBADGE_CHECK" }, + [1334] = { "EVENT_PASSED_EARTHBADGE_CHECK" }, + [1336] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1" }, + [1337] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_0" }, + [1338] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_1" }, + [1339] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_2" }, + [1340] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_3" }, + [1341] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_4" }, + [1342] = { "EVENT_BEAT_MOLTRES" }, + [1343] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2" }, + [1344] = { "EVENT_GOT_NUGGET" }, + [1345] = { "EVENT_BEAT_ROUTE24_ROCKET" }, + [1346] = { "EVENT_BEAT_ROUTE_24_TRAINER_0" }, + [1347] = { "EVENT_BEAT_ROUTE_24_TRAINER_1" }, + [1348] = { "EVENT_BEAT_ROUTE_24_TRAINER_2" }, + [1349] = { "EVENT_BEAT_ROUTE_24_TRAINER_3" }, + [1350] = { "EVENT_BEAT_ROUTE_24_TRAINER_4" }, + [1351] = { "EVENT_BEAT_ROUTE_24_TRAINER_5" }, + [1353] = { "EVENT_NUGGET_REWARD_AVAILABLE" }, + [1359] = { "EVENT_54F" }, + [1360] = { "EVENT_MET_BILL" }, + [1361] = { "EVENT_BEAT_ROUTE_25_TRAINER_0" }, + [1362] = { "EVENT_BEAT_ROUTE_25_TRAINER_1" }, + [1363] = { "EVENT_BEAT_ROUTE_25_TRAINER_2" }, + [1364] = { "EVENT_BEAT_ROUTE_25_TRAINER_3" }, + [1365] = { "EVENT_BEAT_ROUTE_25_TRAINER_4" }, + [1366] = { "EVENT_BEAT_ROUTE_25_TRAINER_5" }, + [1367] = { "EVENT_BEAT_ROUTE_25_TRAINER_6" }, + [1368] = { "EVENT_BEAT_ROUTE_25_TRAINER_7" }, + [1369] = { "EVENT_BEAT_ROUTE_25_TRAINER_8" }, + [1371] = { "EVENT_USED_CELL_SEPARATOR_ON_BILL" }, + [1372] = { "EVENT_GOT_SS_TICKET" }, + [1373] = { "EVENT_MET_BILL_2" }, + [1374] = { "EVENT_BILL_SAID_USE_CELL_SEPARATOR" }, + [1375] = { "EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING" }, + [1378] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_0" }, + [1379] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_1" }, + [1380] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2" }, + [1381] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_3" }, + [1382] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_4" }, + [1393] = { "EVENT_BEAT_MT_MOON_1_TRAINER_0" }, + [1394] = { "EVENT_BEAT_MT_MOON_1_TRAINER_1" }, + [1395] = { "EVENT_BEAT_MT_MOON_1_TRAINER_2" }, + [1396] = { "EVENT_BEAT_MT_MOON_1_TRAINER_3" }, + [1397] = { "EVENT_BEAT_MT_MOON_1_TRAINER_4" }, + [1398] = { "EVENT_BEAT_MT_MOON_1_TRAINER_5" }, + [1399] = { "EVENT_BEAT_MT_MOON_1_TRAINER_6" }, + [1400] = { "EVENT_GOT_DOME_FOSSIL" }, + [1401] = { "EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD" }, + [1402] = { "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" }, + [1403] = { "EVENT_BEAT_MT_MOON_3_TRAINER_0" }, + [1404] = { "EVENT_BEAT_MT_MOON_3_TRAINER_1" }, + [1405] = { "EVENT_BEAT_MT_MOON_3_TRAINER_2" }, + [1406] = { "EVENT_57E" }, + [1407] = { "EVENT_GOT_HELIX_FOSSIL" }, + [1476] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_0" }, + [1477] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_1" }, + [1504] = { "EVENT_GOT_HM01" }, + [1505] = { "EVENT_RUBBED_CAPTAINS_BACK" }, + [1506] = { "EVENT_SS_ANNE_LEFT" }, + [1507] = { "EVENT_WALKED_PAST_GUARD_AFTER_SS_ANNE_LEFT" }, + [1508] = { "EVENT_STARTED_WALKING_OUT_OF_DOCK" }, + [1509] = { "EVENT_WALKED_OUT_OF_DOCK" }, + [1521] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_0" }, + [1522] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_1" }, + [1523] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_2" }, + [1524] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_3" }, + [1537] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_0" }, + [1538] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_1" }, + [1539] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_2" }, + [1540] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_3" }, + [1553] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_0" }, + [1554] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_1" }, + [1555] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_2" }, + [1556] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_3" }, + [1557] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_4" }, + [1558] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_5" }, + [1632] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1" }, + [1633] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0" }, + [1634] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1" }, + [1635] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2" }, + [1636] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3" }, + [1638] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2" }, + [1649] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0" }, + [1650] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1" }, + [1651] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2" }, + [1652] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3" }, + [1653] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" }, + [1655] = { "EVENT_ENTERED_ROCKET_HIDEOUT" }, + [1663] = { "EVENT_67F" }, + [1665] = { "EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0" }, + [1681] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0" }, + [1682] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1" }, + [1696] = { "EVENT_6A0" }, + [1698] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES" }, + [1699] = { "EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT" }, + [1700] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2" }, + [1701] = { "EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED" }, + [1702] = { "EVENT_ROCKET_DROPPED_LIFT_KEY" }, + [1703] = { "EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI" }, + [1778] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_0" }, + [1779] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_1" }, + [1780] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_2" }, + [1781] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_3" }, + [1789] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR1" }, + [1790] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR2" }, + [1791] = { "EVENT_GOT_TM36" }, + [1794] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_0" }, + [1795] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_1" }, + [1800] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR1" }, + [1801] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR2" }, + [1810] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_0" }, + [1811] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_1" }, + [1812] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_2" }, + [1816] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR1" }, + [1817] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR2" }, + [1826] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_0" }, + [1827] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_1" }, + [1828] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_2" }, + [1829] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_3" }, + [1832] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR1" }, + [1833] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR2" }, + [1834] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR3" }, + [1846] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_0" }, + [1847] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_1" }, + [1848] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_2" }, + [1855] = { "EVENT_SILPH_CO_6_UNLOCKED_DOOR" }, + [1856] = { "EVENT_BEAT_SILPH_CO_RIVAL" }, + [1861] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_0" }, + [1862] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_1" }, + [1863] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_2" }, + [1864] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_3" }, + [1868] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR1" }, + [1869] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR2" }, + [1870] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR3" }, + [1874] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_0" }, + [1875] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_1" }, + [1876] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_2" }, + [1880] = { "EVENT_SILPH_CO_8_UNLOCKED_DOOR" }, + [1890] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_0" }, + [1891] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_1" }, + [1892] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_2" }, + [1896] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR1" }, + [1897] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR2" }, + [1898] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR3" }, + [1899] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR4" }, + [1905] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_0" }, + [1906] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_1" }, + [1912] = { "EVENT_SILPH_CO_10_UNLOCKED_DOOR" }, + [1920] = { "EVENT_780" }, + [1921] = { "EVENT_781" }, + [1922] = { "EVENT_782" }, + [1924] = { "EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES" }, + [1925] = { "EVENT_BEAT_SILPH_CO_11F_TRAINER_0" }, + [1928] = { "EVENT_SILPH_CO_11_UNLOCKED_DOOR" }, + [1933] = { "EVENT_GOT_MASTER_BALL" }, + [1935] = { "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + [2049] = { "EVENT_BEAT_MANSION_2_TRAINER_0" }, + [2065] = { "EVENT_BEAT_MANSION_3_TRAINER_0" }, + [2066] = { "EVENT_BEAT_MANSION_3_TRAINER_1" }, + [2081] = { "EVENT_BEAT_MANSION_4_TRAINER_0" }, + [2082] = { "EVENT_BEAT_MANSION_4_TRAINER_1" }, + [2176] = { "EVENT_GOT_HM03" }, + [2241] = { "EVENT_BEAT_MEWTWO" }, + [2273] = { "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0" }, + [2278] = { "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM" }, + [2281] = { "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0" }, + [2286] = { "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM" }, + [2289] = { "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0" }, + [2294] = { "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM" }, + [2297] = { "EVENT_BEAT_LANCES_ROOM_TRAINER_0" }, + [2302] = { "EVENT_BEAT_LANCE" }, + [2303] = { "EVENT_LANCES_ROOM_LOCK_DOOR" }, + [2305] = { "EVENT_BEAT_CHAMPION_RIVAL" }, + [2321] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_0" }, + [2322] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_1" }, + [2327] = { "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH" }, + [2481] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0" }, + [2482] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1" }, + [2483] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2" }, + [2484] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3" }, + [2485] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4" }, + [2486] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5" }, + [2487] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6" }, + [2488] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7" }, + [2496] = { "EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE" }, + [2497] = { "EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE" }, + [2504] = { "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE" }, + [2505] = { "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE" }, + [2512] = { "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE" }, + [2513] = { "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE" }, + [2522] = { "EVENT_BEAT_ARTICUNO" }, + }, +} diff --git a/tests/api3_contract/flags.lua b/tests/api3_contract/flags.lua new file mode 100644 index 000000000..ec36d5ddf --- /dev/null +++ b/tests/api3_contract/flags.lua @@ -0,0 +1,72 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/flags%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "flags") + if backend == "gen1" and f.gen == 2 then + f.ow.events = require("src.world.gen2.Events").new() + elseif backend == "gen1" and f.gen == 3 then + package.loaded["src.core.game3.scripting.space"].store = { flags = f.game.save.flags, vars = {} } + end + if f.gen == 1 then + f.number, f.symbol = 0, "EVENT_FOLLOWED_OAK_INTO_LAB" + elseif f.gen == 2 then + f.number, f.symbol = 16, "EVENT_GOT_HM01_CUT" + else + f.symbol = "FLAG_SYS_POKEDEX_GET" + local catalog = dofile( + codeRoot .. "/../../src/mods/flag_names/" .. (version == "emerald" and "emerald" or "firered") .. ".lua" + ) + for number, row in pairs(catalog.records) do + for _, alias in ipairs(row) do + if alias == f.symbol then + f.number = number + end + end + end + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local report = { invalid = {} } + for _, name in ipairs({ -1, 0.5, math.huge, "UNKNOWN_NATIVE_FLAG", {}, false }) do + local value, why = f.exports.set(name, true) + assert( + value == nil + and ( + why == "invalid or unknown flag" + or version == "crystal" + and type(name) == "string" + and why == "symbolic event flags unavailable for Crystal; use native ids" + ), + tostring(why) + ) + assert(#f.exports.events() == 0) + report.invalid[#report.invalid + 1] = why + end + assert(f.exports.get(f.number) == false) + assert(f.exports.set(f.number, true)) + assert(f.exports.get(f.number) == true) + assert(f.exports.set(f.number, true)) + assert(#f.exports.events() == 1) + if version == "crystal" then + local value, why = f.exports.get(f.symbol) + assert(value == nil and why == "symbolic event flags unavailable for Crystal; use native ids") + report.symbol = why + else + assert(f.exports.get(f.symbol) == true) + assert(f.exports.set(f.symbol, false)) + assert(f.exports.get(f.number) == false) + assert(#f.exports.events() == 2) + report.symbol = true + end + report.events = f.exports.events() + assert(f.exports.set("mod:quest", true)) + assert(f.exports.get("mod.flags.quest") == true) + local value, why = f.exports.set("mod.other.quest", true) + assert(value == nil and why == "foreign mod flag") + report.private = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/mods/flags/main.lua b/tests/api3_contract/mods/flags/main.lua new file mode 100644 index 000000000..d15a56810 --- /dev/null +++ b/tests/api3_contract/mods/flags/main.lua @@ -0,0 +1,17 @@ +local mod = ... +local world = mod.world +local value, why = world:getFlag(1) +assert(value == nil and why == "no game") +local events = {} +mod.events:on("flag.changed", function(ev) + events[#events + 1] = { name = ev.id or ev.name, value = ev.value } +end) +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.set = function(name, value) + return world:setFlag(name, value) +end +mod.exports.events = function() + return events +end diff --git a/tests/api3_contract/mods/flags/manifest.json b/tests/api3_contract/mods/flags/manifest.json new file mode 100644 index 000000000..0394a27f6 --- /dev/null +++ b/tests/api3_contract/mods/flags/manifest.json @@ -0,0 +1 @@ +{"id":"flags","name":"Native flag contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 42bcaf08b..01b53092d 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -15,11 +15,14 @@ assert( or mode == "npcs" or mode == "wild_battle" or mode == "invalidate" + or mode == "flags" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "invalidate" then +if mode == "flags" then + dofile(codeRoot .. "/flags.lua").run(root, backend, version) +elseif mode == "invalidate" then dofile(codeRoot .. "/invalidate.lua").run(root, backend, version) elseif mode == "wild_battle" then dofile(codeRoot .. "/wild_battle.lua").run(root, backend, version) diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index 5bc2d8895..83d92fc1b 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -195,7 +195,7 @@ do ) T.eq(next(save.flags), nil, "private flags never touch native flags") T.eq(world:setFlag("mod:bad", "yes"), nil, "non-boolean flag rejected") - T.eq(world:setFlag("NATIVE_FLAG", true), nil, "unverified native flag mapping refused") + T.eq(world:setFlag("NATIVE_FLAG", true), nil, "unknown native flag refused") T.eq(world:setFlag("mod:", true), nil, "empty private flag rejected") save.modData.probe.worldFlags = false T.eq(world:setFlag("mod:test", true), nil, "malformed store rejected") diff --git a/tests/engine/api3_flags.lua b/tests/engine/api3_flags.lua new file mode 100644 index 000000000..aa9366cf2 --- /dev/null +++ b/tests/engine/api3_flags.lua @@ -0,0 +1,113 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.flags") +local F = require("src.mods.Api3Flags") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared native flags: " .. tostring(why)) +end +-- Every generated catalog alias resolves to the live VM/save-converter key. +local vm = require("src.script.Gen3ScriptVM") +for _, version in ipairs({ "red", "blue", "yellow", "firered", "emerald" }) do + local key = version == "blue" and "red" or version + local data = require("src.mods.flag_names." .. key) + local gen = Version.generation(version) + for number, row in pairs(data.records) do + local expected = gen == 1 and row[1] or vm.flagName(number) + if row[1] then + T.eq(F.resolve(gen, version, number), expected, "catalog numeric resolves to native key") + end + for _, alias in ipairs(row) do + if alias then + T.eq(F.resolve(gen, version, alias), expected, "catalog alias resolves to native key") + end + end + end +end +for _, version in ipairs({ "gold", "silver", "crystal" }) do + for number = 0, 2047 do + T.eq( + F.resolve(2, version, number), + require("src.script.Gen2Flags").eventFlag(number), + "native Gen2 bit key" + ) + end +end +for _, version in ipairs({ "prism", "polishedcrystal" }) do + T.eq(F.resolve(2, version, 16), nil, "no cartridge flag numbering guessed for hacks") +end +for _, bad in ipairs({ {}, false, 0 / 0, math.huge, -1, 0.5, string.rep("A", 257), "FLAG\0BAD" }) do + local f = fixture() + T.eq(f.exports.set(bad, true), nil, "malformed native ref refused") + T.eq(next(f.game.save.flags), nil, "refusal leaves native store untouched") +end +for _, bad in ipairs({ 0, "yes", {}, function() end }) do + local f = fixture() + T.eq(f.exports.set(f.number, bad), nil, "real boolean required") + T.eq(next(f.game.save.flags), nil, "bad value not stored") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local storage = assert(F.resolve(f.gen, version, f.number)) + T.check(f.exports.set(f.number, true), "native set") + T.eq(f.game.save.flags[storage], true, "native VM sees same flag") + T.check(f.exports.set(f.number, false), "native clear") + T.eq(f.game.save.flags[storage], false, "clear distinguished from untouched for visibility") + T.eq(f.exports.get(f.number), false, "public clear boolean") + f.game.save.flags = false + T.eq(f.exports.get(f.number), nil, "malformed native store refused") + T.eq(f.exports.set(f.number, true), nil, "malformed native store not overwritten") + T.eq(f.game.save.flags, false, "malformed native store retained") +end +do + local f = fixture("firered") + local storage = assert(F.resolve(3, "firered", f.number)) + local own = { index = 1, eventFlag = storage } + local other = { index = 2, eventFlag = "unrelated" } + f.ow.map.def.objects = { own, other } + local calls = {} + function f.ow:syncObjectVisibility(object) + calls[#calls + 1] = object + end + T.check(f.exports.set(f.symbol, true), "visibility flag set") + T.eq(#calls, 1, "only affected actor synchronized") + T.eq(calls[1], own, "synchronization scoped to native flag") + T.eq(f.ow.map.def.objects[2], other, "unrelated template unchanged") +end +do + local f = fixture() + local observed = 0 + f.loader.events:on("flag.changed", function() + observed = observed + 1 + local ok, why = f.exports.set(f.number, false) + T.eq(ok, nil, "flag event cannot reenter a write") + T.eq(why, "world is busy", "flag reentry diagnosed") + end, 0, "probe") + T.check(f.exports.set(f.number, true), "outer write accepted") + T.eq(observed, 1, "no recursive event loop") + T.eq(f.exports.get(f.number), true, "reentrant attempt cannot alter committed flag") +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + if action == "rollback" then + f.loader:_rollback("flags") + elseif action == "reload" then + fixture() + else + Loader.teardown() + end + T.eq(f.exports.set(f.number, true), nil, "retired native setter") + T.eq(f.exports.set("mod:key", true), nil, "retired private setter") + T.eq(next(f.game.save.flags), nil, "retired setter no effects") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 native flags") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 290c3c3a8..bc062183a 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures diff --git a/tools/flag-catalog-check/generate.py b/tools/flag-catalog-check/generate.py new file mode 100644 index 000000000..e385d9931 --- /dev/null +++ b/tools/flag-catalog-check/generate.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Reproduce/check flag identifier catalogs against the clean pinned reference. + +Exports identifiers only. No Gen1 engine code or cartridge ROM is vendored. +""" +import argparse +import json +from pathlib import Path +import subprocess + +PIN = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +EXPORT = r""" +package.path = './?.lua;./?/init.lua;' .. package.path +local out = { reference = '79ea62f45fb2ea8f65d081a109431e4eb1bbdb36', catalogs = {} } +for _, version in ipairs({'red','yellow'}) do + local suffix = version == 'yellow' and '_yellow' or '' + local names=require('src.save_convert.data.event_flags'..suffix).byBit + local encoded={} for k,v in pairs(names) do encoded['n'..k]=v end + out.catalogs[version] = { names=encoded } +end +for _, version in ipairs({'firered','emerald'}) do + require('src.core.GameVersion').set(version) + local t = require('src.core.game3.scripting.flags').forVersion(version) + local ids,names,count={},{},t.FLAGS_COUNT or 0x900 + for k,v in pairs(t.IDS) do + if type(k)=='string' and type(v)=='number' and v>=1 and v=1 and k Date: Thu, 22 Oct 2026 15:16:17 +0200 Subject: [PATCH 19/71] Add prevalidated portable flag script commands --- docs/api3-portability.md | 4 +- docs/portable-world-scripts.md | 45 +++++- src/mods/Api3.lua | 4 +- src/mods/Api3Flags.lua | 45 ++++-- src/mods/PortableScripts.lua | 60 +++++++- src/mods/ScriptProgram.lua | 33 +++- .../api3_contract/mods/script_flags/main.lua | 20 +++ .../mods/script_flags/manifest.json | 1 + tests/api3_contract/script_flags.lua | 89 +++++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_flags.lua | 141 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 12 files changed, 420 insertions(+), 29 deletions(-) create mode 100644 tests/api3_contract/mods/script_flags/main.lua create mode 100644 tests/api3_contract/mods/script_flags/manifest.json create mode 100644 tests/api3_contract/script_flags.lua create mode 100644 tests/engine/portable_script_flags.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 4a15a97c3..b23fdd2a0 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -134,8 +134,8 @@ The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. -Eight object/block cases and eight NPC cases extend this to 72 comparisons -and 144 isolated loader runs (including wild-battle, map-invalidation and flag cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 80 comparisons +and 160 isolated loader runs (including wild-battle, map-invalidation, flag and script-flag cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index 1391146ba..bf411dc1d 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -144,7 +144,7 @@ Unknown/unimplemented portable verbs return false. The existing native ## Scope and verification This first adapter covers dialogue/control flow across the 15 supported -version selections (Gen 1–5). Flags, items/Pokémon, world movement/warps, +version selections (Gen 1–5). Items/Pokémon, world movement/warps, battles, shops and other Gen1Recomp portable verbs need separately verified adapters; they refuse the complete list before the first effect here. @@ -162,3 +162,46 @@ native failures. Fixture runs do not establish rendered retail-ROM coverage. When the unified content/callback PRs are also present, run `PORTABLE_SCRIPT_ALL_FORMATS=1 luajit tests/engine/portable_world_scripts.lua` to repeat the suite with all three format opt-ins enabled together. + +## Portable flag commands + +The validated language also supports: + +| Row | Meaning | +| --- | --- | +| `{ "set_flag", reference }` / `{ "setflag", reference }` | Set a private/native boolean flag. | +| `{ "clear_flag", reference }` / `{ "clearflag", reference }` | Clear it. | +| `{ "check_flag", reference }` | Set the conditional result to the flag's boolean value. | +| `{ "wait_flag", reference, timeout? }` | Yield until true or until the optional timeout expires; set conditional result true/false. | + +References use [API-3 flag addressing](api3-flags.md): `mod:` is private to +the initiating mod, while native symbols/numbers retain their version-specific +meaning. Crystal native references remain numeric-only; unknown hack mappings +are declined. These rows are available through the unified script opt-in and +API 3. Gen1's pinned reference does not offer the host's `scripts` capability +query surface; portable API-3 mods can use the common world/queueScript query, +while host-specific tools may inspect scripts/. + +Every reference, including unreachable rows, is validated and copied before +admission. All referenced save stores are checked before the first effect; +an invalid later row never permits an earlier set_flag to execute. Native and +private writes share the public flag implementation, including private ownership, +native storage, reentry protection and Gen3 visibility synchronization. Native +flag handling uses the already resolved world and never asks the loader to boot +Game. + +A timeout is a whole number from 0 to 36000 logic frames. Omission waits until +signaled or cancelled. Even a zero timeout or already-set flag yields once; +on the next condition poll a true flag wins over timeout expiry. Waiting pauses +under a foreign covering screen, preserving its lifetime and timeout. Waits do +not consume the script instruction budget on every frame. Existing cancellation, +owner/session checks, one-shot completion and instruction/slice budgets apply. +A flag store that becomes malformed after admission fails the run and restores +the original runner; already executed native writes are not rolled back. + +The identical public-only shared mod verifies branching, both aliases, signal +resume, timeout and complete-list refusal on both engines for eight versions. +It extends the pinned suite to 80 comparisons /160 isolated loader runs. +Local tests additionally verify detached rows, malformed stores/timeouts, paused +foreign overlays, native flag conditions and stale-session/owner cancellation. +The existing 5268-check dialogue suite remains unchanged and passing. diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 906983463..3118f4cde 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -117,7 +117,9 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) end if name == "setFlag" or name == "getFlag" then local key, value = ... - return Flags.call(game, api.id, gen, key, value, name == "setFlag", licensed, native) + return Flags.call(game, api.id, gen, key, value, name == "setFlag", licensed, function(live) + return native(live):overworld() + end) end if name == "queueScript" then return scripts:queueScript(...) diff --git a/src/mods/Api3Flags.lua b/src/mods/Api3Flags.lua index d619025b2..c7b937c73 100644 --- a/src/mods/Api3Flags.lua +++ b/src/mods/Api3Flags.lua @@ -81,17 +81,8 @@ function F.resolve(generation, version, name) end return nil, "invalid or unknown flag" end -function F.call(game, owner, gen, name, value, write, licensed, native) - if write and not licensed() then - return nil, "world facade is retired" - end - if write and type(value) ~= "boolean" then - return nil, "flag value must be boolean" - end - local save = game.save - if not save then - return nil, "no save" - end +-- Resolve without a Game/save for whole-program script preflight. +function F.address(owner, gen, version, name) local key if type(name) == "string" then if name:sub(1, 4) == "mod:" then @@ -108,6 +99,31 @@ function F.call(game, owner, gen, name, value, write, licensed, native) if not identifier(key) then return nil, "invalid mod flag" end + return { kind = "mod", key = key } + end + local storage, public, number = F.resolve(gen, version, name) + if not storage then + return nil, public + end + return { kind = "native", key = storage, public = public, number = number } +end +function F.call(game, owner, gen, name, value, write, licensed, overworld) + if write and not licensed() then + return nil, "world facade is retired" + end + if write and type(value) ~= "boolean" then + return nil, "flag value must be boolean" + end + local save = game.save + if not save then + return nil, "no save" + end + local address, reason = F.address(owner, gen, Version.get(), name) + if not address then + return nil, reason + end + if address.kind == "mod" then + local key = address.key local store = save.modData if store ~= nil and type(store) ~= "table" then return nil, "invalid mod save store" @@ -143,10 +159,7 @@ function F.call(game, owner, gen, name, value, write, licensed, native) end return true end - local storage, public, number = F.resolve(gen, Version.get(), name) - if not storage then - return nil, public - end + local storage, public, number = address.key, address.public, address.number if type(save.flags) ~= "table" then return nil, "invalid flag store" end @@ -162,7 +175,7 @@ function F.call(game, owner, gen, name, value, write, licensed, native) -- Native clear keeps false: visibility distinguishes cleared from untouched. save.flags[storage] = value if gen == 3 then - local ow = native(game):overworld() + local ow = overworld(game) if ow and ow.syncObjectVisibility then for _, object in ipairs(ow.map and ow.map.def and ow.map.def.objects or {}) do if object.eventFlag == storage then diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index bfa6c6181..46d77fb24 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -2,6 +2,7 @@ -- foreground until completion, then returns the idle runner it borrowed. local Program = require("src.mods.ScriptProgram") local Runtime = require("src.mods.Runtime") +local Flags = require("src.mods.Api3Flags") local GameVersion = require("src.core.GameVersion") local Scripts = {} local runs = setmetatable({}, { __mode = "k" }) @@ -187,7 +188,10 @@ local function facade(loader, modId, native) if not licensed() then return nil, "script facade is retired" end - local program, why = Program.compile(rows) + local program, why = Program.compile( + rows, + { owner = modId, generation = GameVersion.generation(), version = GameVersion.get() } + ) if not program then return nil, why end @@ -208,6 +212,25 @@ local function facade(loader, modId, native) if not ow then return nil, why end + local function flag(name, value, write) + return Flags.call(game, modId, GameVersion.generation(), name, value, write, licensed, function() + return ow + end) + end + -- Check stores for every row, including unreachable branches, before a prefix runs. + for i, row in ipairs(program) do + if + row[1] == "set_flag" + or row[1] == "clear_flag" + or row[1] == "check_flag" + or row[1] == "wait_flag" + then + local value, reason = flag(row[2]) + if value == nil then + return nil, "row " .. i .. ": " .. reason + end + end + end local Runner = require("src.script.ScriptRunner") local runner = Runner.new(game, ow) local token = { @@ -418,7 +441,40 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if verb == "wait" then + if verb == "set_flag" or verb == "clear_flag" then + local ok, reason = flag(row[2], verb == "set_flag", true) + if not ok then + error(reason, 0) + end + elseif verb == "check_flag" then + local value, reason = flag(row[2]) + if value == nil then + error(reason, 0) + end + ctx.lastCheck = value + elseif verb == "wait_flag" then + local left = row[3] + self.waitingCheck = function() + if not check() then + return false + end + local value, reason = flag(row[2]) + if value == nil then + error(reason, 0) + end + if value then + return true, true + end + if left then + left = left - 1 + if left <= 0 then + return true, false + end + end + return false + end + ctx.lastCheck = self:yield() + elseif verb == "wait" then self.waitingFrames = row[2] self:yield() elseif verb == "show_text" or verb == "ask" then diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 4bfc0a813..22418d75b 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -2,6 +2,10 @@ local Program = {} Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 local ARITY = { + set_flag = 2, + clear_flag = 2, + check_flag = 2, + wait_flag = 3, show_text = 2, ask = 2, choice = 3, @@ -12,8 +16,9 @@ local ARITY = { jump_if_false = 2, ["end"] = 1, } +local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) - return type(verb) == "string" and (ARITY[verb] ~= nil or verb == "text") + return type(verb) == "string" and (ARITY[aliases[verb] or verb] ~= nil) end local function integer(n, lo, hi) return type(n) == "number" and n == n and n % 1 == 0 and n >= lo and n <= hi @@ -69,7 +74,8 @@ local function text(value, max) end return true end -function Program.compile(raw) +function Program.compile(raw, context) + context = context or {} local n = array(raw, Program.MAX_ROWS) if not n then return nil, "script must be a dense plain array of at most 1024 rows" @@ -83,15 +89,32 @@ function Program.compile(raw) if not count or count == 0 then return fail(i, "expected a dense plain command row") end - local verb = source[1] == "text" and "show_text" or source[1] + local verb = aliases[source[1]] or source[1] if type(verb) ~= "string" or not ARITY[verb] then return fail(i, "unsupported portable command") end - if count > ARITY[verb] or count < (verb == "choice" and 2 or ARITY[verb]) then + if count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb]) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if verb == "show_text" or verb == "ask" then + if verb == "set_flag" or verb == "clear_flag" or verb == "check_flag" or verb == "wait_flag" then + local Version = require("src.core.GameVersion") + local address, reason = require("src.mods.Api3Flags").address( + context.owner or "", + context.generation or Version.generation(), + context.version or Version.get(), + source[2] + ) + if not address then + return fail(i, reason) + end + if verb == "wait_flag" then + if source[3] ~= nil and not integer(source[3], 0, 36000) then + return fail(i, "invalid flag timeout") + end + row[3] = source[3] + end + elseif verb == "show_text" or verb == "ask" then if not text(source[2], 4096) then return fail(i, "text must be valid UTF-8 without NUL, at most 4096 bytes") end diff --git a/tests/api3_contract/mods/script_flags/main.lua b/tests/api3_contract/mods/script_flags/main.lua new file mode 100644 index 000000000..3a96b1e6b --- /dev/null +++ b/tests/api3_contract/mods/script_flags/main.lua @@ -0,0 +1,20 @@ +local mod = ... +local world = mod.world +assert(mod:supports("world", "queueScript")) +local done, success = 0 +mod.exports.queue = function(rows) + return world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.set = function(name, value) + return world:setFlag(name, value) +end +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.result = function() + return { done = done, success = success } +end diff --git a/tests/api3_contract/mods/script_flags/manifest.json b/tests/api3_contract/mods/script_flags/manifest.json new file mode 100644 index 000000000..2dcca5648 --- /dev/null +++ b/tests/api3_contract/mods/script_flags/manifest.json @@ -0,0 +1 @@ +{"id":"script_flags","name":"Portable script flags","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_flags.lua b/tests/api3_contract/script_flags.lua new file mode 100644 index 000000000..3d2ef8bbc --- /dev/null +++ b/tests/api3_contract/script_flags.lua @@ -0,0 +1,89 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_flags%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "script_flags") + f.loader.game = f.game + f.ow.runner.update = function() end + if f.field then + function f.field.lock(token) + f.field._locks[token] = true + end + function f.field.unlock(token) + f.field._locks[token] = nil + end + end + function f:tick() + if backend == "gen1" then + self.loader.hooks:call("core.update", function() end, self.game, 1 / 60) + else + self.loader.hooks:call("input.step", function() + self.ow.runner:update() + end, self.game, 1 / 60) + end + end + function f:drain() + for _ = 1, 100 do + self:tick() + if self.exports.result().done > 0 then + return + end + end + error("script did not complete") + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local rows = { + { "set_flag", "mod:ready" }, + { "check_flag", "mod:ready" }, + { "jump_if_false", "failed" }, + { "clearflag", "mod:ready" }, + { "check_flag", "mod:ready" }, + { "jump_if_true", "failed" }, + { "setflag", "mod:result" }, + { "jump", "end" }, + { "label", "failed" }, + { "set_flag", "mod:failure" }, + } + assert(f.exports.queue(rows)) + f:drain() + assert(f.exports.get("mod:result") == true and f.exports.get("mod:failure") == false) + assert(f.exports.get("mod:ready") == false and f.exports.result().success == true) + local report = { branch = true } + f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "wait_flag", "mod:signal", 20 }, + { "jump_if_false", "end" }, + { + "set_flag", + "mod:resumed", + }, + })) + f:tick() + assert(f.exports.result().done == 0) + assert(f.exports.set("mod:signal", true)) + f:drain() + assert(f.exports.get("mod:resumed") == true) + report.resumed = f.exports.result() + f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "wait_flag", "mod:signal", 2 }, + { "jump_if_true", "end" }, + { + "set_flag", + "mod:timeout", + }, + })) + f:drain() + assert(f.exports.get("mod:timeout") == true) + report.timeout = f.exports.result() + f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "set_flag", "mod:prefix" }, { "set_flag", "not_a_real_flag" } }) + assert(value == nil and type(why) == "string") + assert(f.exports.get("mod:prefix") == false and f.exports.result().done == 0) + report.preflight = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 01b53092d..c5812249d 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -16,11 +16,14 @@ assert( or mode == "wild_battle" or mode == "invalidate" or mode == "flags" + or mode == "script_flags" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "flags" then +if mode == "script_flags" then + dofile(codeRoot .. "/script_flags.lua").run(root, backend, version) +elseif mode == "flags" then dofile(codeRoot .. "/flags.lua").run(root, backend, version) elseif mode == "invalidate" then dofile(codeRoot .. "/invalidate.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_flags.lua b/tests/engine/portable_script_flags.lua new file mode 100644 index 000000000..0f25d1d66 --- /dev/null +++ b/tests/engine/portable_script_flags.lua @@ -0,0 +1,141 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_flags") +local Program = require("src.mods.ScriptProgram") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared script flags: " .. tostring(why)) +end +for _, verb in ipairs({ "set_flag", "clear_flag", "check_flag", "wait_flag", "setflag", "clearflag" }) do + T.eq(Program.supports(verb), true, "flag verb advertised") +end +for _, rows in ipairs({ + { { "set_flag", "mod:prefix" }, { "set_flag", "no_such_flag" } }, + { { "jump", "end" }, { "set_flag", "no_such_flag" } }, + { { "set_flag", "mod:prefix" }, { "check_flag", "mod.other.secret" } }, + { { "set_flag", {} } }, + { { "set_flag", "mod:" } }, + { { "set_flag", "mod:x", true } }, + { { "wait_flag", "mod:x", -1 } }, + { { "wait_flag", "mod:x", 0.5 } }, + { { "wait_flag", "mod:x", math.huge } }, + { { "wait_flag", "mod:x", 36001 } }, + { { "wait_flag", "mod:x", "5" } }, +}) do + local f = fixture() + local runner = f.ow.runner + local value, why = f.exports.queue(rows) + T.eq(value, nil, "entire invalid list declined") + T.check(type(why) == "string", "invalid row explained") + T.eq(f.exports.get("mod:prefix"), false, "no accepted prefix executes") + T.eq(f.ow.runner, runner, "no runner replacement on refusal") + T.eq(f.exports.result().done, 0, "no completion on refusal") +end +do + local f = fixture() + f.game.save.modData = false + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" } }), nil, "malformed store rejected before admission") + T.eq(f.game.save.modData, false, "malformed store not overwritten") +end +do + local f = fixture() + local rows = { { "wait_flag", "mod:signal" }, { "set_flag", "mod:copied" } } + T.check(f.exports.queue(rows), "indefinite wait admitted") + rows[2][2] = "mod:mutated" + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.result().done, 0, "omitted timeout keeps waiting") + T.check(f.exports.set("mod:signal", true), "external signal") + f:drain() + T.eq(f.exports.get("mod:copied"), true, "admitted rows detached") + T.eq(f.exports.get("mod:mutated"), false, "caller mutation ignored") +end +for _, timeout in ipairs({ 0, 1, 2 }) do + local f = fixture() + T.check( + f.exports.queue({ + { "wait_flag", "mod:signal", timeout }, + { "jump_if_true", "end" }, + { + "set_flag", + "mod:timed", + }, + }), + "timeout admitted" + ) + T.eq(f.exports.result().done, 0, "even zero yields once") + f:drain() + T.eq(f.exports.get("mod:timed"), true, "timeout sets lastCheck false") +end +do + local f = fixture() + T.check( + f.exports.queue({ { "wait_flag", "mod:signal", 1 }, { "set_flag", "mod:resumed" } }), + "wait admitted before overlay" + ) + local overlay = {} + f.game.stack.states[2] = overlay + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.result().done, 0, "foreign overlay pauses timeout") + T.eq(f.game.stack.states[2], overlay, "owned wait does not pop foreign screen") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.result().success, true, "wait resumes after uncovering") +end +for _, change in ipairs({ "save", "data", "map", "version", "rollback", "teardown" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "wait_flag", "mod:signal" }, { "set_flag", "mod:late" } }), + "wait starts before " .. change + ) + local owned = f.ow.runner + if change == "version" then + Version.set("blue") + elseif change == "rollback" then + f.loader:_rollback("script_flags") + elseif change == "teardown" then + Loader.teardown() + elseif change == "map" then + f.ow.map = { id = f.home, def = f.ow.map.def } + else + f.game[change] = {} + end + f:tick() + T.eq(f.exports.result().done, 1, "changed context completes once") + T.eq(f.exports.result().success, false, "changed context cancels wait") + T.eq(f.ow.runner, original, "original runner restored") + owned:resume(true) + T.eq(f.exports.result().done, 1, "late condition cannot replay completion") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local ref = version == "red" and 0 or (f.gen == 2 and 16 or 1) + T.check( + f.exports.queue({ + { "set_flag", ref }, + { "check_flag", ref }, + { "jump_if_false", "end" }, + { "set_flag", "mod:native" }, + { "clear_flag", ref }, + }), + "native flag program admitted" + ) + f:drain() + T.eq(f.exports.get("mod:native"), true, "native condition drives control flow") + T.eq(f.exports.get(ref), false, "native flag cleared") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script flags") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index bc062183a..222f47222 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 235b8f975f9e4c74cf0775dc6f18e9cc7220439b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:26:29 +0200 Subject: [PATCH 20/71] Add portable wallet, inventory and party script actions --- docs/api3-portability.md | 4 +- docs/portable-world-scripts.md | 38 ++++ src/mods/PortableScripts.lua | 9 +- src/mods/ScriptProgram.lua | 17 +- src/mods/ScriptState.lua | 193 ++++++++++++++++++ src/pokemon/Pokemon.lua | 4 +- .../api3_contract/mods/script_state/main.lua | 16 ++ .../mods/script_state/manifest.json | 1 + tests/api3_contract/script_flags.lua | 4 +- tests/api3_contract/script_state.lua | 109 ++++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_state.lua | 109 ++++++++++ tools/api3-contract-check/check.py | 2 +- 13 files changed, 500 insertions(+), 11 deletions(-) create mode 100644 src/mods/ScriptState.lua create mode 100644 tests/api3_contract/mods/script_state/main.lua create mode 100644 tests/api3_contract/mods/script_state/manifest.json create mode 100644 tests/api3_contract/script_state.lua create mode 100644 tests/engine/portable_script_state.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index b23fdd2a0..f80db8650 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -134,8 +134,8 @@ The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. -Eight object/block cases and eight NPC cases extend this to 80 comparisons -and 160 isolated loader runs (including wild-battle, map-invalidation, flag and script-flag cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 88 comparisons +and 176 isolated loader runs (including world-action, flag, script-flag and script-state cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index bf411dc1d..a9445b950 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -205,3 +205,41 @@ It extends the pinned suite to 80 comparisons /160 isolated loader runs. Local tests additionally verify detached rows, malformed stores/timeouts, paused foreign overlays, native flag conditions and stale-session/owner cancellation. The existing 5268-check dialogue suite remains unchanged and passing. + +## Wallet, inventory and party state commands + +The bounded language also accepts these synchronous rows: + +| Row | Meaning | +| --- | --- | +| `{ "give_money", amount }` / `{ "take_money", amount }` | Add/subtract money; subtraction saturates at zero. | +| `{ "check_money", amount }` | Set the conditional result to whether the current wallet covers the amount. | +| `{ "take_coins", amount }` | Subtract coins, saturating at zero. | +| `{ "check_item", itemId }` | Set the conditional result to whether the bag contains the item. | +| `{ "take_item", itemId, count? }` | Remove up to count (default one), using native Bag.remove/order cleanup. | +| `{ "check_dex_owned", amount? }` | Check the owned/caught species count, default threshold one. | +| `{ "mark_seen", speciesId }` | Mark an existing species seen using the host's native storage key. | +| `{ "heal_party" }` | Heal native party HP/status and restore PP from the current Game's move data. | + +Amounts are finite whole nonnegative numbers up to Lua's exact integer limit +(2^53-1). Item count is 1–999. Item/species IDs are canonical strings, resolved +through the existing loader index rather than assumed cartridge numbers. +Missing/ambiguous content, malformed referenced wallets/inventory/dex/party +stores or malformed arguments reject the complete list before any effect, +including unreachable rows. Accepted rows are detached; live stores are checked +again when dispatching after a wait. Native effects already executed before a +later runtime failure are not rolled back. + +Wallet writes retain the verified Gen1 API's generation behavior: Gen2/3 +addition caps at 999999; Gen1 retains its native unbounded script wallet. +Queries and item/coin removal do not create gifts or UI. Inventory and party +structures retain this host's native layouts. The optional moves argument on +Pokemon.heal lets this path use Game.data instead of an unrelated Data singleton; +existing native callers keep their default behavior. + +The same public script compares branches and resulting money, coins, item +quantity, HP/PP and seen state through both native backends on eight versions. +The full pinned checker now runs 88 comparisons /176 isolated loader runs. +Host tests add malformed/unreachable-row refusal with no prefix effects, wallet +caps, saturation, actual Bag order removal, live-store failure and argument +copying. These are ROM-free runtime/store checks, not a story playthrough. diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index 46d77fb24..82eadbebb 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -3,6 +3,7 @@ local Program = require("src.mods.ScriptProgram") local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") +local State = require("src.mods.ScriptState") local GameVersion = require("src.core.GameVersion") local Scripts = {} local runs = setmetatable({}, { __mode = "k" }) @@ -231,6 +232,10 @@ local function facade(loader, modId, native) end end end + local state, reason = State.prepare(loader, game, ow, program) + if not state then + return nil, reason + end local Runner = require("src.script.ScriptRunner") local runner = Runner.new(game, ow) local token = { @@ -441,7 +446,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if verb == "set_flag" or verb == "clear_flag" then + if State.ARITY[verb] then + state(ctx, row) + elseif verb == "set_flag" or verb == "clear_flag" then local ok, reason = flag(row[2], verb == "set_flag", true) if not ok then error(reason, 0) diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 22418d75b..a7101fedb 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -1,5 +1,6 @@ -- Pure preflight for the bounded portable dialogue/control-flow language. local Program = {} +local State = require("src.mods.ScriptState") Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 local ARITY = { set_flag = 2, @@ -16,6 +17,9 @@ local ARITY = { jump_if_false = 2, ["end"] = 1, } +for verb, arity in pairs(State.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) return type(verb) == "string" and (ARITY[aliases[verb] or verb] ~= nil) @@ -93,11 +97,20 @@ function Program.compile(raw, context) if type(verb) ~= "string" or not ARITY[verb] then return fail(i, "unsupported portable command") end - if count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb]) then + if + not State.ARITY[verb] + and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) + then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if verb == "set_flag" or verb == "clear_flag" or verb == "check_flag" or verb == "wait_flag" then + if State.ARITY[verb] then + local value, reason = State.syntax(verb, source) + if not value then + return fail(i, reason) + end + row = value + elseif verb == "set_flag" or verb == "clear_flag" or verb == "check_flag" or verb == "wait_flag" then local Version = require("src.core.GameVersion") local address, reason = require("src.mods.Api3Flags").address( context.owner or "", diff --git a/src/mods/ScriptState.lua b/src/mods/ScriptState.lua new file mode 100644 index 000000000..dbf73a6e5 --- /dev/null +++ b/src/mods/ScriptState.lua @@ -0,0 +1,193 @@ +-- Synchronous portable script actions over the host's native save/party/bag. +local Ids = require("src.mods.UnifiedIds") +local Version = require("src.core.GameVersion") +local S = {} +S.ARITY = { + give_money = 2, + take_money = 2, + check_money = 2, + take_coins = 2, + check_item = 2, + take_item = 3, + check_dex_owned = 2, + mark_seen = 2, + heal_party = 1, +} +local function integer(value, minimum, maximum) + return type(value) == "number" + and value == value + and value % 1 == 0 + and value >= minimum + and value < math.huge + and (not maximum or value <= maximum) +end +local function identifier(value) + return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c") +end +function S.syntax(verb, source) + local n = S.ARITY[verb] + local minimum = (verb == "take_item" or verb == "check_dex_owned") and 2 or n + if verb == "check_dex_owned" then + minimum = 1 + end + if #source < minimum or #source > n then + return nil, "invalid command arity" + end + if verb == "check_item" or verb == "take_item" or verb == "mark_seen" then + if not identifier(source[2]) then + return nil, "invalid content id" + end + if verb == "take_item" and source[3] ~= nil and not integer(source[3], 1, 999) then + return nil, "invalid item count" + end + elseif verb ~= "heal_party" then + if + not (verb == "check_dex_owned" and source[2] == nil) and not integer(source[2], 0, 9007199254740991) + then + return nil, "invalid script amount" + end + end + return { verb, source[2], source[3] } +end +local function tableOrNil(value) + return value == nil or type(value) == "table" +end +function S.prepare(loader, game, world, rows) + local ids = Ids.new(loader) + local plans = {} + local save = game.save + local function number(value) + return value == nil or integer(value, 0, 9007199254740991) + end + local function validate(row) + local verb = row[1] + if S.ARITY[verb] then + local reason, key + if verb == "give_money" or verb == "take_money" or verb == "check_money" then + if not number(save.money) then + reason = "invalid money store" + elseif + verb == "give_money" + and Version.generation() == 1 + and not integer((save.money or 0) + row[2], 0, 9007199254740991) + then + reason = "money overflow" + end + elseif verb == "take_coins" then + if not number(save.coins) then + reason = "invalid coin store" + end + elseif verb == "check_item" or verb == "take_item" or verb == "mark_seen" then + local kind = verb == "mark_seen" and "pokemon" or "items" + local group, why = ids:resolve(kind, row[2]) + if not group or not (game.data[kind] and game.data[kind][group.key]) then + reason = why or "content unavailable" + else + key = group.key + end + if not reason and kind == "items" then + if + type(save.inventory) ~= "table" + or not number(save.inventory[key]) + or not tableOrNil(save.bagOrder) + then + reason = "invalid inventory store" + end + end + end + if verb == "mark_seen" or verb == "check_dex_owned" then + local dex = save.pokedex + if + not tableOrNil(dex) + or dex and (not tableOrNil(dex.seen) or not tableOrNil(dex.owned) or not tableOrNil(dex.caught)) + then + reason = "invalid pokedex store" + end + elseif verb == "heal_party" then + if type(save.party) ~= "table" then + reason = "invalid party" + else + for _, mon in ipairs(save.party) do + if + type(mon) ~= "table" + or type(mon.stats) ~= "table" + or not integer(mon.stats.hp, 1, 65535) + or not tableOrNil(mon.moves) + then + reason = "invalid party" + break + end + for _, move in ipairs(mon.moves or {}) do + if type(move) ~= "table" or not (game.data.moves and game.data.moves[move.id]) then + reason = "invalid party move" + break + end + end + end + end + end + if reason then + return nil, reason + end + return { key = key } + end + end + for i, row in ipairs(rows) do + if S.ARITY[row[1]] then + local plan, reason = validate(row) + if not plan then + return nil, "row " .. i .. ": " .. reason + end + plans[row] = plan + end + end + return function(ctx, row) + local verb, a = row[1], row[2] + local plan = plans[row] + if not plan then + return false + end + -- Revalidate live stores too: another mod can mutate them while this script waits. + local checked, reason = validate(row) + if not checked then + error(reason, 0) + end + if verb == "give_money" or verb == "take_money" then + local value = math.max(0, (save.money or 0) + (verb == "give_money" and a or -a)) + if Version.generation() ~= 1 then + value = math.min(999999, value) + end + save.money = value + elseif verb == "check_money" then + ctx.lastCheck = (save.money or 0) >= a + elseif verb == "take_coins" then + save.coins = math.max(0, (save.coins or 0) - a) + elseif verb == "check_item" then + ctx.lastCheck = (save.inventory[plan.key] or 0) > 0 + elseif verb == "take_item" then + local n = math.min(row[3] or 1, save.inventory[plan.key] or 0) + if n > 0 then + require("src.inventory.Bag").remove(save, plan.key, n) + end + elseif verb == "mark_seen" then + save.pokedex = save.pokedex or {} + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.seen[plan.key] = true + elseif verb == "check_dex_owned" then + local dex = save.pokedex + local n = 0 + for _, value in pairs(dex and (dex.owned or dex.caught) or {}) do + if value then + n = n + 1 + end + end + ctx.lastCheck = n >= (a or 1) + elseif verb == "heal_party" then + for _, mon in ipairs(save.party) do + require("src.pokemon.Pokemon").heal(mon, game.data.moves) + end + end + return true + end +end +return S diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 79c91e7fe..2ed5e2137 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -467,10 +467,10 @@ function Pokemon.restorePP(mon, moves) end end -function Pokemon.heal(mon) +function Pokemon.heal(mon, moves) mon.hp = mon.stats.hp mon.status = nil - Pokemon.restorePP(mon) + Pokemon.restorePP(mon, moves) end function Pokemon.isFainted(mon) diff --git a/tests/api3_contract/mods/script_state/main.lua b/tests/api3_contract/mods/script_state/main.lua new file mode 100644 index 000000000..4c1039daf --- /dev/null +++ b/tests/api3_contract/mods/script_state/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local world = mod.world +local done, success = 0 +mod.exports.queue = function(rows) + return world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.result = function() + return { done = done, success = success } +end diff --git a/tests/api3_contract/mods/script_state/manifest.json b/tests/api3_contract/mods/script_state/manifest.json new file mode 100644 index 000000000..3f29a475f --- /dev/null +++ b/tests/api3_contract/mods/script_state/manifest.json @@ -0,0 +1 @@ +{"id":"script_state","name":"Portable state actions","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_flags.lua b/tests/api3_contract/script_flags.lua index 3d2ef8bbc..af3020908 100644 --- a/tests/api3_contract/script_flags.lua +++ b/tests/api3_contract/script_flags.lua @@ -1,7 +1,7 @@ local M = {} -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_flags%.lua$") - local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "script_flags") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "script_flags") f.loader.game = f.game f.ow.runner.update = function() end if f.field then diff --git a/tests/api3_contract/script_state.lua b/tests/api3_contract/script_state.lua new file mode 100644 index 000000000..33bb3eb28 --- /dev/null +++ b/tests/api3_contract/script_state.lua @@ -0,0 +1,109 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_state%.lua$") + local f = dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, "script_state") + local save, data = f.game.save, f.game.data + save.money, save.coins = 100, 50 + save.inventory, save.bagOrder = { POTION = 3 }, { "POTION" } + save.pokedex = { seen = {}, owned = { MEW = true } } + save.party = + { { species = "MEW", hp = 1, status = "PSN", stats = { hp = 20 }, moves = { { id = "POUND", pp = 1 } } } } + f.pp = function() + return save.party[1].moves[1].pp + end + f.seen = function() + return save.pokedex.seen.MEW == true + end + f.quantity = function() + return save.inventory.POTION or 0 + end + f.wallet = function() + return save.money, save.coins + end + if backend == "gen1" and f.gen == 1 then + require("src.core.Data").moves = data.moves -- Native heal uses the active data singleton. + elseif backend == "gen1" and f.gen == 2 then + f.ow.game = f.game + save.player = { money = 100, coins = 50 } + local World = require("src.world.gen2.World") + f.ow.money, f.ow.setMoney = World.money, World.setMoney + f.ow.coins, f.ow.setCoins = World.coins, World.setCoins + f.ow.healParty = World.healParty + save.party[1].maxHp = 20 + f.wallet = function() + return save.player.money, save.player.coins + end + elseif backend == "gen1" and f.gen == 3 then + local pokemon = require("src.core.game3.pokemon") + for key, value in pairs(data.gen3Pokemon) do + pokemon[key] = value + end + pokemon._byName = { MEW = 151 } + require("src.core.game3.items_data").installPack({ items = data.gen3Items._byId }) + local Bag = require("src.core.game3.bag") + save.bag = Bag.new() + save.bag.pockets.ITEMS = { { id = 13, qty = 3 } } + save.dex = { seen = {}, caught = { [151] = true }, owned = { [151] = true } } + save.party = + { { species = 151, hp = 1, maxHp = 20, status = 8, moves = { 1 }, pp = { 1 }, maxPp = { 35 } } } + f.pp = function() + return save.party[1].pp[1] + end + f.seen = function() + return require("src.core.game3.dex").isSeen(save.dex, 151) + end + f.quantity = function() + return Bag.get(save.bag, 13) + end + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "give_money", 50 }, + { "take_money", 20 }, + { "check_money", 130 }, + { "jump_if_false", "failed" }, + { "take_coins", 12 }, + { "check_item", "POTION" }, + { "jump_if_false", "failed" }, + { "take_item", "POTION", 2 }, + { "check_dex_owned", 1 }, + { "jump_if_false", "failed" }, + { "mark_seen", "MEW" }, + { "heal_party" }, + { "set_flag", "mod:passed" }, + { "jump", "end" }, + { "label", "failed" }, + { "set_flag", "mod:failed" }, + })) + f:drain() + assert(f.exports.result().success == true) + assert(f.exports.get("mod:passed") == true and f.exports.get("mod:failed") == false) + local money, coins = f.wallet() + local report = { + money = money, + coins = coins, + items = f.quantity(), + hp = f.game.save.party[1].hp, + pp = f.pp(), + seen = f.seen(), + } + assert( + money == 130 + and coins == 38 + and report.items == 1 + and report.hp == 20 + and report.pp == 35 + and report.seen == true + ) + f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "give_money", 1 }, { "take_item", "NO_SUCH_ITEM" } }) + assert(value == nil and type(why) == "string") + assert(f.wallet() == 100 and f.quantity() == 3 and f.exports.result().done == 0) + report.preflight = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index c5812249d..23aeea734 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -17,11 +17,14 @@ assert( or mode == "invalidate" or mode == "flags" or mode == "script_flags" + or mode == "script_state" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_flags" then +if mode == "script_state" then + dofile(codeRoot .. "/script_state.lua").run(root, backend, version) +elseif mode == "script_flags" then dofile(codeRoot .. "/script_flags.lua").run(root, backend, version) elseif mode == "flags" then dofile(codeRoot .. "/flags.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_state.lua b/tests/engine/portable_script_state.lua new file mode 100644 index 000000000..4dd84c487 --- /dev/null +++ b/tests/engine/portable_script_state.lua @@ -0,0 +1,109 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_state") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared state actions: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "give_money", 1 }, { "take_item", "UNKNOWN" } }, + { { "jump", "end" }, { "mark_seen", "UNKNOWN" } }, + { { "give_money", -1 } }, + { { "take_money", 0.5 } }, + { { "give_money", math.huge } }, + { { "check_money", "10" } }, + { { "take_coins", false } }, + { { "take_item", "POTION", 0 } }, + { { "take_item", "POTION", 1000 } }, + { { "check_dex_owned", -1 } }, + { { "heal_party", true } }, + { { "check_item", {} } }, +}) do + local f = fixture() + local runner = f.ow.runner + T.eq(f.exports.queue(rows), nil, "whole invalid program declined") + T.eq(f.wallet(), 100, "money prefix not executed") + T.eq(f.quantity(), 3, "inventory untouched") + T.eq(f.ow.runner, runner, "no runner installed on refusal") + T.eq(f.exports.result().done, 0, "no completion on refusal") +end +for _, store in ipairs({ "money", "coins", "inventory", "bagOrder", "pokedex", "party" }) do + local f = fixture() + f.game.save[store] = false + local row = ({ + money = { "give_money", 1 }, + coins = { "take_coins", 1 }, + inventory = { "take_item", "POTION" }, + bagOrder = { "take_item", "POTION" }, + pokedex = { "mark_seen", "MEW" }, + party = { "heal_party" }, + })[store] + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, row }), nil, "malformed " .. store .. " declined") + T.eq(f.exports.get("mod:prefix"), false, "store failure prevents prefix") + T.eq(f.game.save[store], false, "malformed store not overwritten") +end +do + local f = fixture() + f.game.save.party[1].stats = nil + T.eq( + f.exports.queue({ { "give_money", 1 }, { "heal_party" } }), + nil, + "malformed party record refused up front" + ) + T.eq(f.wallet(), 100, "party refusal preserves wallet") +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(version) + f.game.save.money = 999990 + T.check( + f.exports.queue({ { "give_money", 50 }, { "take_coins", 999 }, { "take_item", "POTION", 999 } }), + "bounded state operations admitted" + ) + f:drain() + T.eq(f.wallet(), version == "red" and 1000040 or 999999, "native generation wallet cap") + T.eq(f.game.save.coins, 0, "coin subtraction saturates at zero") + T.eq(f.quantity(), 0, "item subtraction removes available quantity only") + T.eq(#f.game.save.bagOrder, 0, "native bag order entry removed") +end +do + local f = fixture() + f.game.save.money = 9007199254740991 + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, { "give_money", 1 } }), nil, "wallet overflow refused") + T.eq(f.exports.get("mod:prefix"), false, "overflow refusal prevents prefix") + T.eq(f.game.save.money, 9007199254740991, "overflow cannot round native wallet") +end +do + local f = fixture() + T.check( + f.exports.queue({ { "check_dex_owned" }, { "jump_if_false", "end" }, { "set_flag", "mod:owned" } }), + "default dex threshold admitted" + ) + f:drain() + T.eq(f.exports.get("mod:owned"), true, "default dex threshold is one") +end +do + local f = fixture() + T.check(f.exports.queue({ { "wait", 1 }, { "take_item", "POTION", 1 } }), "state action waits") + f.game.save.inventory = false + f:drain() + T.eq(f.exports.result().success, false, "changed live store fails safely") + T.eq(f.game.save.inventory, false, "changed malformed store not overwritten") +end +do + local f = fixture() + local rows = { { "wait", 1 }, { "give_money", 10 } } + T.check(f.exports.queue(rows), "copy program admitted") + rows[2][2] = 100 + f:drain() + T.eq(f.wallet(), 110, "accepted scalar arguments detached") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script state actions") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 222f47222..1ee1e75b4 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 76717887264f26b93bc0343e27e23b93c607b88a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:48:09 +0200 Subject: [PATCH 21/71] Add portable native gift scripts with owned UI continuations --- docs/api3-portability.md | 4 +- docs/portable-world-scripts.md | 38 ++++ src/mods/PortableScripts.lua | 77 ++++++- src/mods/ScriptGifts.lua | 135 ++++++++++++ src/mods/ScriptProgram.lua | 23 +- src/script/Commands.lua | 39 ++-- .../api3_contract/mods/script_gifts/main.lua | 16 ++ .../mods/script_gifts/manifest.json | 1 + tests/api3_contract/script_gifts.lua | 57 +++++ tests/api3_contract/script_state.lua | 4 +- tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_gifts.lua | 199 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 13 files changed, 579 insertions(+), 21 deletions(-) create mode 100644 src/mods/ScriptGifts.lua create mode 100644 tests/api3_contract/mods/script_gifts/main.lua create mode 100644 tests/api3_contract/mods/script_gifts/manifest.json create mode 100644 tests/api3_contract/script_gifts.lua create mode 100644 tests/engine/portable_script_gifts.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index f80db8650..2e35ab5aa 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -134,8 +134,8 @@ The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. -Eight object/block cases and eight NPC cases extend this to 88 comparisons -and 176 isolated loader runs (including world-action, flag, script-flag and script-state cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 96 comparisons +and 192 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index a9445b950..350da5f24 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -243,3 +243,41 @@ The full pinned checker now runs 88 comparisons /176 isolated loader runs. Host tests add malformed/unreachable-row refusal with no prefix effects, wallet caps, saturation, actual Bag order removal, live-store failure and argument copying. These are ROM-free runtime/store checks, not a story playthrough. + +## Item and Pokemon gifts + +- `{ "give_item", itemId, count?, gotText?, bagFullText? }`: use the native + bag gift command. Count defaults to one (1–999). Optional receipt/full-bag + text is literal UTF-8 up to 4096 bytes; `gotText=false` suppresses the receipt. + A full bag shows its native failure message and halts before later gift flags. +- `{ "give_pokemon", speciesId, level, skipNickname?, gotText? }`: use native + creation, OT stamping, party/box placement and nickname flow. Level is 1–100; + the optional flags are actual booleans. `gotText=true` requests a receipt + before the nickname question. A full party sends the gift through native box + storage and waits for its sent-to-box text; completely full storage leaves + the native conditional result false for the script to branch on. + +The complete row list's content references, counts/options and referenced +inventory/party/player/dex/box store shapes are checked before a prefix runs. +IDs are canonical, but native bag capacities, native Pokemon fields and native +storage layouts remain the host's. Already entered native effects are not a +transaction. Scripts cannot pass executable callbacks or native options into +these rows. Gifts do not introduce new species constructors or storage slots. + +The isolated runner adopts exact native screens created by the gift operation +and screens subsequently created by those owned screens. Native receipt, +YES/NO and naming callbacks receive per-operation, per-phase one-shot guards; +an old receipt/question/naming callback cannot mutate a nickname twice, bypass +a later screen or skip the next row's wait. Cancellation removes owned screens +and retains a foreign overlay. A mod retired by its before-give hook cannot +continue into native Pokemon construction. Native callers outside the portable +context retain their existing callback/default behavior. + +The same public mod on both engines verifies item and Pokemon gifts with +receipt/nickname suppressed, real native bag/party/dex effects and whole-list +preflight refusal. This extends the pinned suite to 96 comparisons /192 isolated +loader runs. Host tests additionally drive actual native receipts, bag-full +messages, YES/NO, naming, box deposits, callback replay and unload/session +cancellation on synthetic data. Rendering/pixel equivalence and retail-ROM +playthroughs are not claimed. Direct mutations of engine objects by other mods +remain outside the portable runner's ownership guarantees. diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index 82eadbebb..fe9743491 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -4,6 +4,7 @@ local Program = require("src.mods.ScriptProgram") local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") +local Gifts = require("src.mods.ScriptGifts") local GameVersion = require("src.core.GameVersion") local Scripts = {} local runs = setmetatable({}, { __mode = "k" }) @@ -236,6 +237,10 @@ local function facade(loader, modId, native) if not state then return nil, reason end + local gift, giftReason = Gifts.prepare(loader, game, ow, program) + if not gift then + return nil, giftReason + end local Runner = require("src.script.ScriptRunner") local runner = Runner.new(game, ow) local token = { @@ -256,6 +261,7 @@ local function facade(loader, modId, native) player = ow.player, version = GameVersion.get(), ui = {}, + ownedUI = {}, steps = 0, } function token.valid() @@ -325,20 +331,82 @@ local function facade(loader, modId, native) end return valid end + local discover + local function snapshot() + local before = {} + for _, state in ipairs(game.stack.states) do + before[state] = true + end + return before + end local function owned(state, step) + if token.ownedUI[state] then + return state + end + token.ownedUI[state] = true token.ui[#token.ui + 1] = state local update = state.update function state:update(...) if not check() or token.step ~= step or game.stack:top() ~= self then return end + local before = token.nativeStep == step and snapshot() local ok, err = engine(update, self, ...) + if before then + discover(before, step) + end if not ok then token.finish(false, "native UI failed: " .. message(err)) end end return state end + discover = function(before, step) + if token.done or token.step ~= step then + return + end + for _, state in ipairs(game.stack.states) do + if not before[state] then + owned(state, step) + if token.nativeStep == step then + token.waitingUI = state + end + end + end + end + local function nativeGift(ctx, row) + local step = token.step + local bound = setmetatable({ + resume = function(_, ...) + if token.step == step and check() then + runner:resume(...) + end + end, + }, { __index = runner, __newindex = runner }) + local revision = 0 + local proxy = setmetatable({ + portableCallback = function(callback) + local expected, fired = revision, false + return function(...) + if fired or expected ~= revision or token.step ~= step or not check() then + return + end + fired = true + revision = revision + 1 + return callback(...) + end + end, + runner = bound, + portableAlive = function() + return token.step == step and check() + end, + }, { __index = ctx, __newindex = ctx }) + token.nativeStep = step + local result = gift(proxy, row) + token.nativeStep = nil + token.waitingUI = nil + return result + end local function dialog(ctx, text, asking, step) local TextBox = require("src.render.TextBox") local box, answered @@ -446,7 +514,11 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if State.ARITY[verb] then + if Gifts.ARITY[verb] then + if nativeGift(ctx, row) == math.huge then + return + end + elseif State.ARITY[verb] then state(ctx, row) elseif verb == "set_flag" or verb == "clear_flag" then local ok, reason = flag(row[2], verb == "set_flag", true) @@ -505,7 +577,10 @@ local function facade(loader, modId, native) if not check() or not self.co or coroutine.status(self.co) == "running" then return end + local before, step = snapshot(), token.step + token.waitingUI = nil local ok, err = coroutine.resume(self.co, ...) + discover(before, token.step or step) if not ok then Runtime.reportError(modId, "portable script failed: " .. message(err)) token.finish(false, "script failed: " .. message(err)) diff --git a/src/mods/ScriptGifts.lua b/src/mods/ScriptGifts.lua new file mode 100644 index 000000000..63b4b535d --- /dev/null +++ b/src/mods/ScriptGifts.lua @@ -0,0 +1,135 @@ +-- Closed portable gift rows; execute through native gift/bag/party/box commands. +local Ids = require("src.mods.UnifiedIds") +local G = { ARITY = { give_item = 5, give_pokemon = 5 } } +local function integer(n, lo, hi) + return type(n) == "number" and n == n and n % 1 == 0 and n >= lo and n <= hi +end +local function id(s) + return type(s) == "string" and #s > 0 and #s <= 256 and not s:find("%c") +end +local function text(s) + return type(s) == "string" and #s <= 4096 and not s:find("%z") +end +function G.syntax(verb, row) + if not id(row[2]) then + return nil, "invalid gift id" + end + if verb == "give_item" then + if #row < 2 or #row > 5 then + return nil, "invalid command arity" + end + if row[3] ~= nil and not integer(row[3], 1, 999) then + return nil, "invalid item count" + end + if row[4] ~= nil and row[4] ~= false and not text(row[4]) then + return nil, "invalid gift text" + end + if row[5] ~= nil and not text(row[5]) then + return nil, "invalid bag full text" + end + else + if #row < 3 or #row > 5 then + return nil, "invalid command arity" + end + if not integer(row[3], 1, 100) then + return nil, "invalid gift level" + end + if row[4] ~= nil and type(row[4]) ~= "boolean" or row[5] ~= nil and type(row[5]) ~= "boolean" then + return nil, "invalid gift option" + end + end + return { row[1], row[2], row[3], row[4], row[5] } +end +function G.prepare(loader, game, world, rows) + local ids = Ids.new(loader) + local plans = {} + local function validate(row) + if G.ARITY[row[1]] then + local kind = row[1] == "give_item" and "items" or "pokemon" + local group, reason = ids:resolve(kind, row[2]) + if not group or not (game.data[kind] and game.data[kind][group.key]) then + return nil, reason or "gift content unavailable" + end + local save = game.save + if row[1] == "give_item" then + if type(save.inventory) ~= "table" or save.bagOrder ~= nil and type(save.bagOrder) ~= "table" then + return nil, "invalid inventory store" + end + for _, count in pairs(save.inventory) do + if not integer(count, 0, 999999) then + return nil, "invalid inventory count" + end + end + elseif + type(save.party) ~= "table" + or type(save.pokedex) ~= "table" + or type(save.pokedex.seen) ~= "table" + or type(save.pokedex.owned) ~= "table" + then + return nil, "invalid gift party/dex store" + end + if row[1] == "give_pokemon" then + if type(save.player) ~= "table" then + return nil, "invalid gift player store" + end + if #save.party > 6 then + return nil, "invalid gift party" + end + for index, mon in pairs(save.party) do + if not integer(index, 1, #save.party) or type(mon) ~= "table" then + return nil, "invalid gift party" + end + end + for index = 1, #save.party do + if type(save.party[index]) ~= "table" then + return nil, "invalid gift party" + end + end + for _, name in ipairs({ "boxes", "box", "boxNames" }) do + if save[name] ~= nil and type(save[name]) ~= "table" then + return nil, "invalid box store" + end + end + if save.currentBox ~= nil and not integer(save.currentBox, 1, 65535) then + return nil, "invalid box index" + end + for _, box in pairs(save.boxes or {}) do + if type(box) ~= "table" then + return nil, "invalid box store" + end + end + end + return group.key + end + end + for i, row in ipairs(rows) do + if G.ARITY[row[1]] then + local key, reason = validate(row) + if not key then + return nil, "row " .. i .. ": " .. reason + end + plans[row] = key + end + end + return function(ctx, row) + local key = plans[row] + if not key then + error("unprepared gift", 0) + end + if not game.data[row[1] == "give_item" and "items" or "pokemon"][key] then + error("gift content unavailable", 0) + end + local ready, reason = validate(row) + if not ready then + error(reason, 0) + end + local Commands = require("src.script.Commands") + if row[1] == "give_item" then + ctx.portableBagFullText = row[5] + return Commands.give_item(ctx, key, row[3], row[4]) + end + ctx.portableGiftText = row[5] + return Commands.give_pokemon(ctx, key, row[3], row[4]) + end +end +return G diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index a7101fedb..6ca6d6c33 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -1,6 +1,7 @@ -- Pure preflight for the bounded portable dialogue/control-flow language. local Program = {} local State = require("src.mods.ScriptState") +local Gifts = require("src.mods.ScriptGifts") Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 local ARITY = { set_flag = 2, @@ -20,6 +21,9 @@ local ARITY = { for verb, arity in pairs(State.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(Gifts.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) return type(verb) == "string" and (ARITY[aliases[verb] or verb] ~= nil) @@ -89,7 +93,7 @@ function Program.compile(raw, context) return nil, "row " .. i .. ": " .. message end for i = 1, n do - local source, count = raw[i], array(raw[i], 3) + local source, count = raw[i], array(raw[i], 5) if not count or count == 0 then return fail(i, "expected a dense plain command row") end @@ -99,12 +103,27 @@ function Program.compile(raw, context) end if not State.ARITY[verb] + and not Gifts.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if State.ARITY[verb] then + if Gifts.ARITY[verb] then + local value, reason = Gifts.syntax(verb, source) + if not value then + return fail(i, reason) + end + row = value + for _, index in ipairs({ 4, 5 }) do + if type(row[index]) == "string" then + if not text(row[index], 4096) then + return fail(i, "gift text must be valid UTF-8 without NUL") + end + bytes = bytes + #row[index] + end + end + elseif State.ARITY[verb] then local value, reason = State.syntax(verb, source) if not value then return fail(i, reason) diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 568ad5dfc..983bc3fc1 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -370,9 +370,9 @@ function Commands.show_text(ctx, textId, subs, extraOpts) -- ctx.lastCheck untouched -- the prompt never appears and the script reads -- the stale value as the answer. if opts and opts.choice then opts.auto = nil end - ctx.game.stack:push(TextBox.new(ctx.game, text, function() - runner:resume() - end, opts)) + local onDone = function() runner:resume() end + if ctx.portableCallback then onDone = ctx.portableCallback(onDone) end + ctx.game.stack:push(TextBox.new(ctx.game, text, onDone, opts)) runner:yield() end @@ -554,7 +554,7 @@ function Commands.give_item(ctx, itemId, count, gotText) if not require("src.inventory.Bag").add( ctx.save, itemId, count or 1, ctx.game.data) then ctx.lastCheck = false - Commands.show_text(ctx, ctx.game.data.text + Commands.show_text(ctx, ctx.portableBagFullText or ctx.game.data.text and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!")) return math.huge end @@ -1221,21 +1221,24 @@ local function askNickname(ctx, mon) -- opts.choice (TextBox.lua): the YES/NO box rides over the still-visible -- question instead of a bare ChoiceBox popping up after an A press -- already cleared it (same fix as Commands.ask). - Commands.show_text(ctx, textId, subs, { choice = function(yes) + local choice = function(yes) if not yes then ctx.lastCheck = success runner:resume() return end + local onDone = function(nick) + if nick and #nick > 0 then mon.nickname = nick end + ctx.lastCheck = success + runner:resume() + end + if ctx.portableCallback then onDone = ctx.portableCallback(onDone) end Screens.push(ctx.game, "NamingScreen", { - title = Strings("NICKNAME?"), maxLen = 10, - onDone = function(nick) - if nick and #nick > 0 then mon.nickname = nick end - ctx.lastCheck = success - runner:resume() - end, + title = Strings("NICKNAME?"), maxLen = 10, onDone = onDone, }) - end }) + end + if ctx.portableCallback then choice = ctx.portableCallback(choice) end + Commands.show_text(ctx, textId, subs, { choice = choice }) end -- give_pokemon : _GivePokemon (engine/events/ @@ -1265,6 +1268,7 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts) ctx.game.mods.events:emit("pokemon.before_give", gift) species, level = gift.species, gift.level end + if ctx.portableAlive and not ctx.portableAlive() then return end local Pokemon = require("src.pokemon.Pokemon") local Party = require("src.pokemon.Party") local Boxes = require("src.pokemon.Boxes") @@ -1314,6 +1318,17 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts) ctx.addedToParty = addedToParty require('src.pokemon.Gen4PoketchState').remember(ctx.game, mon) ctx.boxNum = boxNum + if ctx.portableGiftText and ctx.runner then + local wait = true + if not gift.nickname and not skipNickname then wait = false end + ctx.textOpts = ctx.textOpts or {} + ctx.textOpts.auto = { + sound = function() return require("src.core.Sound").play(ctx.game.data, "Get_Item1") end, + wait = wait, + } + Commands.show_text(ctx, ctx.game.data.text and ctx.game.data.text._GotMonText + or Strings("{PLAYER} received\n%s!", ctx.game.stringBuffer)) + end -- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks -- and callback-style callers with no script runner to yield on. if not gift.nickname and not skipNickname and ctx.runner then diff --git a/tests/api3_contract/mods/script_gifts/main.lua b/tests/api3_contract/mods/script_gifts/main.lua new file mode 100644 index 000000000..4c1039daf --- /dev/null +++ b/tests/api3_contract/mods/script_gifts/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local world = mod.world +local done, success = 0 +mod.exports.queue = function(rows) + return world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.result = function() + return { done = done, success = success } +end diff --git a/tests/api3_contract/mods/script_gifts/manifest.json b/tests/api3_contract/mods/script_gifts/manifest.json new file mode 100644 index 000000000..7129948d2 --- /dev/null +++ b/tests/api3_contract/mods/script_gifts/manifest.json @@ -0,0 +1 @@ +{"id":"script_gifts","name":"Portable gifts","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_gifts.lua b/tests/api3_contract/script_gifts.lua new file mode 100644 index 000000000..9aee71bb0 --- /dev/null +++ b/tests/api3_contract/script_gifts.lua @@ -0,0 +1,57 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_gifts%.lua$") + local f = dofile(codeRoot .. "/script_state.lua").fixture(root, backend, version, "script_gifts") + love = love or require("tests.love_stub") + local data, save = f.game.data, f.game.save + data.text = data.text or {} + data.font = data.font or dofile(codeRoot .. "/../fixture_data/font.lua") + if not getmetatable(data) then + setmetatable(data, { __index = require("src.core.Data") }) + end + save.player = save.player or {} + save.player.name, save.player.id = "TEST", 12345 + f.game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + local Stack = require("src.core.StateStack") + setmetatable(f.game.stack, { __index = Stack }) + f.ow.update = function(self) + self.runner:update() + end + require("src.render.Font").load(data) + if backend == "gen1" and f.gen == 3 then + local pokemon = require("src.core.game3.pokemon") + pokemon._learnsets, pokemon._national, pokemon._abilityNames = {}, { [151] = 151 }, {} + end + f.party = function() + local mon = save.party[#save.party] + return { count = #save.party, level = mon.level, species = (mon.species == 151 and "MEW" or mon.species) } + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "give_item", "POTION", 2, false }, + { "give_pokemon", "MEW", 5, true, false }, + { "set_flag", "mod:gifted" }, + })) + f:drain() + assert(f.exports.result().success == true and f.exports.get("mod:gifted") == true) + assert(f.quantity() == 5 and #f.game.save.party == 2) + local report = { items = f.quantity(), party = f.party() } + f = M.fixture(root, backend, version) + local value, why = + f.exports.queue({ { "give_item", "POTION", 1, false }, { "give_pokemon", "UNKNOWN", 5, true, false } }) + assert(value == nil and type(why) == "string" and f.quantity() == 3 and #f.game.save.party == 1) + report.preflight = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/script_state.lua b/tests/api3_contract/script_state.lua index 33bb3eb28..dc7148928 100644 --- a/tests/api3_contract/script_state.lua +++ b/tests/api3_contract/script_state.lua @@ -1,7 +1,7 @@ local M = {} -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_state%.lua$") - local f = dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, "script_state") + local f = dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, modId or "script_state") local save, data = f.game.save, f.game.data save.money, save.coins = 100, 50 save.inventory, save.bagOrder = { POTION = 3 }, { "POTION" } diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 23aeea734..0b9872292 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -18,11 +18,14 @@ assert( or mode == "flags" or mode == "script_flags" or mode == "script_state" + or mode == "script_gifts" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_state" then +if mode == "script_gifts" then + dofile(codeRoot .. "/script_gifts.lua").run(root, backend, version) +elseif mode == "script_state" then dofile(codeRoot .. "/script_state.lua").run(root, backend, version) elseif mode == "script_flags" then dofile(codeRoot .. "/script_flags.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_gifts.lua b/tests/engine/portable_script_gifts.lua new file mode 100644 index 000000000..f1a42964e --- /dev/null +++ b/tests/engine/portable_script_gifts.lua @@ -0,0 +1,199 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_gifts") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +local function tick(f, key) + f.game.input.wasPressed = function(_, button) + return button == key + end + f.loader.hooks:call("input.step", function() end, f.game, 1 / 60) + f.game.stack:update(1 / 60) + f.game.input.wasPressed = function() + return false + end +end +local function close(f) + for _ = 1, 2000 do + if f.exports.result().done > 0 then + return + end + tick(f, "a") + end + error("gift UI did not close") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared gift contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "give_item", "POTION", 1, false }, { "give_pokemon", "UNKNOWN", 5, true, false } }, + { { "give_item", "POTION", 0 } }, + { { "give_item", "POTION", 1000 } }, + { { "give_item", "POTION", 1, true } }, + { { "give_item", "POTION", 1, "\255" } }, + { { "give_pokemon", "MEW", 0 } }, + { { "give_pokemon", "MEW", 101 } }, + { { "give_pokemon", "MEW", 5, "yes" } }, + { { "give_pokemon", "MEW", 5, true, "text" } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid gift list refused") + T.eq(f.quantity(), 3, "gift prefix does not write inventory") + T.eq(#f.game.save.party, 1, "gift prefix does not create party mon") + T.eq(f.exports.result().done, 0, "refusal has no completion") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + T.check( + f.exports.queue({ { "give_item", "POTION", 1, "RECEIVED POTION!" }, { "set_flag", "mod:after" } }), + "native receipt admitted" + ) + T.eq(f.quantity(), 4, "native bag already contains received item") + T.eq(f.exports.result().done, 0, "script waits for native receipt") + close(f) + T.eq(f.exports.result().success, true, "receipt completes native script") + T.eq(f.exports.get("mod:after"), true, "next command runs after receipt only") + T.eq(f.game.stack:top(), f.ow, "owned receipt removed") +end +do + local f = fixture() + T.check( + f.exports.queue({ { "give_pokemon", "MEW", 5, true, true }, { "set_flag", "mod:after" } }), + "native mon receipt admitted" + ) + T.eq(#f.game.save.party, 2, "native mon gift created once") + T.eq(f.exports.result().done, 0, "native mon receipt waits") + close(f) + T.eq(f.exports.result().success, true, "native mon receipt complete") + T.eq(f.exports.get("mod:after"), true, "mon gift resumes script") +end +do + local f = fixture() + f.game.save.inventory.POTION = 99 + T.check( + f.exports.queue({ { "give_item", "POTION", 1, false, "BAG IS FULL!" }, { "set_flag", "mod:burned" } }), + "full bag admitted for native refusal UI" + ) + T.eq(f.quantity(), 99, "full bag adds nothing") + T.eq(f.exports.result().done, 0, "bag full message awaited") + close(f) + T.eq(f.exports.result().success, true, "native full bag halts cleanly") + T.eq(f.exports.get("mod:burned"), false, "full bag cannot burn later gift flag") +end +for _, action in ipairs({ "rollback", "teardown", "save" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "give_item", "POTION", 1, "RECEIVED!" }, { "set_flag", "mod:after" } }), + "receipt before " .. action + ) + local box = f.game.stack:top() + local overlay = { update = function() end } + f.game.stack:push(overlay) + if action == "rollback" then + f.loader:_rollback("script_gifts") + elseif action == "teardown" then + Loader.teardown() + else + f.game.save = {} + tick(f) + end + T.eq(f.game.stack:top(), overlay, "foreign overlay retained") + T.eq(#f.game.stack.states, 2, "only owned receipt removed") + T.eq(f.ow.runner, original, "native original runner restored") + T.eq(f.exports.result().done, 1, "cancelled receipt completes once") + T.eq(f.exports.result().success, false, "cancelled receipt reports failure") + box.onDone() + T.eq(f.exports.result().done, 1, "retained native callback cannot replay cancellation") + T.eq(f.game.stack:top(), overlay, "retained native callback preserves foreign overlay") +end +for _, field in ipairs({ "player", "party", "pokedex", "boxes", "boxNames", "currentBox" }) do + local f = fixture() + f.game.save[field] = false + T.eq( + f.exports.queue({ { "give_item", "POTION", 1, false }, { "give_pokemon", "MEW", 5, true, false } }), + nil, + "malformed " .. field .. " preflight" + ) + T.eq(f.quantity(), 3, "malformed gift destination prevents prefix") +end +do + local f = fixture() + T.check( + f.exports.queue({ { "give_pokemon", "MEW", 5, false, false }, { "wait", 5 }, { "set_flag", "mod:after" } }), + "native nickname flow admitted" + ) + local question = f.game.stack:top() + local Choice = require("src.ui.ChoiceBox") + for _ = 1, 2000 do + if getmetatable(f.game.stack:top()) == Choice then + break + end + tick(f, "a") + end + T.eq(getmetatable(f.game.stack:top()), Choice, "native question opens native choice") + tick(f, "a") + for _ = 1, 40 do + tick(f) + end + local naming = f.game.stack:top() + T.check( + naming ~= f.ow and naming ~= question and getmetatable(naming) ~= Choice, + "native naming screen opened" + ) + T.eq(type(naming.onDone), "function", "native naming completion exists") + question.onDone() + question.choice(false) + T.eq(f.game.stack:top(), naming, "old question callbacks cannot bypass naming") + T.eq(f.exports.result().done, 0, "old question cannot complete gift") + f.game.stack:pop() + naming.onDone("SCOUT") + naming.onDone("REPLAY") + T.eq(f.game.save.party[2].nickname, "SCOUT", "native naming callback commits once") + T.eq(f.exports.result().done, 0, "stale callback cannot skip later wait") + for _ = 1, 5 do + tick(f) + end + T.eq(f.exports.get("mod:after"), true, "native nickname flow resumes normally") +end +do + local f = fixture() + for i = 2, 6 do + f.game.save.party[i] = require("src.mods.Merge").deepCopy(f.game.save.party[1]) + end + T.check( + f.exports.queue({ { "give_pokemon", "MEW", 5, true, false }, { "set_flag", "mod:boxed" } }), + "native box gift admitted" + ) + T.eq(#f.game.save.party, 6, "full party retained") + T.check(f.game.save.boxes[1][1], "actual native box receives gift") + T.eq(f.exports.result().done, 0, "native sent-to-box message awaited") + close(f) + T.eq(f.exports.get("mod:boxed"), true, "box message resumes script") +end +do + local f = fixture() + local party = f.game.save.party + f.game.mods = { events = f.loader.events } + f.loader.events:on("pokemon.before_give", function() + f.loader:_rollback("script_gifts") + end, 0, "probe") + T.check( + f.exports.queue({ { "give_pokemon", "MEW", 5, true, false } }), + "gift admission reaches native hook" + ) + T.eq(#party, 1, "owner retired in before_give cannot construct a gift") + T.eq(f.exports.result().done, 1, "mid-construction cancellation completes once") + T.eq(f.exports.result().success, false, "mid-construction cancellation reported") + T.eq(f.game.stack:top(), f.ow, "cancelled native gift opens no late UI") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script gifts") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 1ee1e75b4..fe0fec4e6 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 25af724d2ae554ec206ec4715ac10fc63b679aaa Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:03:33 +0200 Subject: [PATCH 22/71] Add validated portable object and block script commands --- docs/api3-portability.md | 2 +- docs/portable-world-scripts.md | 19 +++ src/mods/Api3WorldEdits.lua | 84 ++++++++----- src/mods/PortableScripts.lua | 57 ++++++++- src/mods/ScriptProgram.lua | 21 +++- src/mods/ScriptWorldEdits.lua | 85 +++++++++++++ .../api3_contract/mods/script_world/main.lua | 26 ++++ .../mods/script_world/manifest.json | 1 + tests/api3_contract/script_world.lua | 66 ++++++++++ tests/api3_contract/worker.lua | 5 +- tests/api3_contract/world_edits.lua | 4 +- tests/engine/portable_script_world.lua | 117 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 13 files changed, 451 insertions(+), 38 deletions(-) create mode 100644 src/mods/ScriptWorldEdits.lua create mode 100644 tests/api3_contract/mods/script_world/main.lua create mode 100644 tests/api3_contract/mods/script_world/manifest.json create mode 100644 tests/api3_contract/script_world.lua create mode 100644 tests/engine/portable_script_world.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 2e35ab5aa..32258f6ae 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -135,7 +135,7 @@ loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. Eight object/block cases and eight NPC cases extend this to 96 comparisons -and 192 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +and 208 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index 350da5f24..d245a750f 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -281,3 +281,22 @@ messages, YES/NO, naming, box deposits, callback replay and unload/session cancellation on synthetic data. Rendering/pixel equivalence and retail-ROM playthroughs are not claimed. Direct mutations of engine objects by other mods remain outside the portable runner's ownership guarantees. + + +## Object and block scripts + +API 3 also accepts `show_object(mapId, objectRef)`, +`hide_object(mapId, objectRef)` and `replace_block(bx, by, blockId)` on +Gen 1–3. Object references must name an existing definition; Gen 2/3 +require the active map. Block coordinates use the same geometry and limits +as `mod.world:replaceBlock`. Validation covers every row before any prefix +runs, then repeats against current stores at dispatch. + +These rows run through the validated native edit plans under the script's +existing foreground ownership. Ordinary public world actions retain their +idle-world admission guard. Gen 1's seamless object refresh may replace +both the map and its default runner: the script continues on that map and +restores the new idle runner when finished. Foreign overlays pause the +program; unloading, changed saves/maps and invalid live stores cancel it. +Edits already committed before cancellation are retained, and public world +events follow the native edit behavior. diff --git a/src/mods/Api3WorldEdits.lua b/src/mods/Api3WorldEdits.lua index 4b48c180d..77d8ce2eb 100644 --- a/src/mods/Api3WorldEdits.lua +++ b/src/mods/Api3WorldEdits.lua @@ -44,7 +44,9 @@ local function toggle(game, ow, api, gen, req, mapId, ref, visible) end if gen == 1 then -- Gen 1 permits off-map keys and resolves their visibility on map load. - return api:toggleObject(id, ref, visible) + return function() + return api:toggleObject(id, ref, visible) + end end if ow.map.id ~= id then return nil, "map is not active: " .. mapId @@ -68,13 +70,12 @@ local function toggle(game, ow, api, gen, req, mapId, ref, visible) return nil, "object runtime unavailable" end local ctx = { game = game, save = save, overworld = ow, mapId = id } + local index = object.index if gen == 2 then if not store(save.g2ObjectToggles) then return nil, "invalid object toggle store" end - req("src.script.Gen2Commands").setObjectVisibility(ctx, object, id, visible) else - local index = object.index if not integer(index, 254) or index == 0 then return nil, "invalid object definition" end @@ -85,13 +86,19 @@ local function toggle(game, ow, api, gen, req, mapId, ref, visible) then return nil, "invalid object toggle store" end - req("src.script.Gen3Commands") - local commands = req("src.script.Commands") - local command = visible and commands.g3_show_object or commands.g3_hide_object - command(ctx, index) end - Runtime.emit("world.object_toggled", { mapId = id, objName = ref, visible = visible }) - return true + return function() + if gen == 2 then + req("src.script.Gen2Commands").setObjectVisibility(ctx, object, id, visible) + else + req("src.script.Gen3Commands") + local commands = req("src.script.Commands") + local command = visible and commands.g3_show_object or commands.g3_hide_object + command(ctx, index) + end + Runtime.emit("world.object_toggled", { mapId = id, objName = ref, visible = visible }) + return true + end end local function replace(game, ow, api, gen, req, bx, by, block) if not integer(block, gen == 3 and 1023 or 255) then @@ -113,22 +120,26 @@ local function replace(game, ow, api, gen, req, bx, by, block) return nil, "block renderer unavailable" end if gen ~= 3 then - return api:replaceBlock(bx, by, block) - end - -- Like Gen1's Gen3 Field.setMetatile default, use the native non-impassable - -- metatile command; it replaces the old collision override as well. - req("src.script.Gen3Commands") - req("src.script.Commands").g3_set_metatile( - { game = game, save = game.save, overworld = ow }, - bx, - by, - block, - 0 - ) - renderer:rebuild() - ow.map.blocksDirty = nil - Runtime.emit("world.block_replaced", { mapId = ow.map.id, bx = bx, by = by, block = block }) - return true + return function() + return api:replaceBlock(bx, by, block) + end + end + return function() + -- Like Gen1's Gen3 Field.setMetatile default, use the native non-impassable + -- metatile command; it replaces the old collision override as well. + req("src.script.Gen3Commands") + req("src.script.Commands").g3_set_metatile( + { game = game, save = game.save, overworld = ow }, + bx, + by, + block, + 0 + ) + renderer:rebuild() + ow.map.blocksDirty = nil + Runtime.emit("world.block_replaced", { mapId = ow.map.id, bx = bx, by = by, block = block }) + return true + end end local function invalidate(game, ow, api, gen, req, mapId) if not identifier(mapId) then @@ -148,7 +159,9 @@ local function invalidate(game, ow, api, gen, req, mapId) if type(ow.reloadMap) ~= "function" then return nil, "map reload unavailable" end - return api:invalidateMap(id) + return function() + return api:invalidateMap(id) + end end -- Shared admission/commit guard for synchronous API-3 world mutations. function E.ready(game, ow) @@ -170,6 +183,15 @@ function E.run(game, operation, ...) end return value, why end +-- Pure validation shared by public edits and an already owned script run. +function E.plan(game, api, generation, req, name, ...) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local operation = name == "toggleObject" and toggle or name == "invalidateMap" and invalidate or replace + return operation(game, ow, api, generation, req, ...) +end function E.call(game, api, generation, req, name, ...) local ow = api:overworld() if not ow then @@ -179,7 +201,13 @@ function E.call(game, api, generation, req, name, ...) if not ok then return nil, reason end - local operation = name == "toggleObject" and toggle or name == "invalidateMap" and invalidate or replace - return E.run(game, operation, game, ow, api, generation, req, ...) + local function execute(...) + local operation, reason = E.plan(game, api, generation, req, name, ...) + if not operation then + return nil, reason + end + return operation() + end + return E.run(game, execute, ...) end return E diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index fe9743491..fa78194bd 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -5,6 +5,7 @@ local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local WorldEdits = require("src.mods.ScriptWorldEdits") local GameVersion = require("src.core.GameVersion") local Scripts = {} local runs = setmetatable({}, { __mode = "k" }) @@ -241,6 +242,10 @@ local function facade(loader, modId, native) if not gift then return nil, giftReason end + local edit, editReason = WorldEdits.prepare(game, ow, modId, program) + if not edit then + return nil, editReason + end local Runner = require("src.script.ScriptRunner") local runner = Runner.new(game, ow) local token = { @@ -277,12 +282,12 @@ local function facade(loader, modId, native) or game.stack.states ~= token.states or not contains(token.stack, ow) or game.overworld and game.overworld ~= ow - or ow.map ~= token.map + or not token.mutating and ow.map ~= token.map or ow.map.id ~= token.mapId or ow.player ~= token.player or ow.player.cellX ~= token.x or ow.player.cellY ~= token.y - or ow.runner ~= runner + or not token.mutating and ow.runner ~= runner or GameVersion.get() ~= token.version or Runtime.hooks ~= loader.hooks or Runtime.events ~= loader.events @@ -331,6 +336,44 @@ local function facade(loader, modId, native) end return valid end + local function mutate(operation) + if not check() then + return nil, "script session changed" + end + token.mutating = true + local called, value, reason = engine(operation) + if + not token.done + and current(loader) == game + and game.save == token.save + and game.data == token.data + and game.stack == token.stack + and ow.player == token.player + then + if + called + and value + and ow.map ~= token.map + and ow.map + and ow.map.id == token.mapId + and ow.runner ~= runner + then + token.original = ow.runner + ow.runner = runner + end + if called and value and ow.map and ow.map.id == token.mapId then + token.map = ow.map + end + end + token.mutating = nil + if not check() then + return nil, "script session changed" + end + if not called then + return nil, message(value) + end + return value, reason + end local discover local function snapshot() local before = {} @@ -514,7 +557,15 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if Gifts.ARITY[verb] then + if WorldEdits.ARITY[verb] then + local value, reason = edit(row, mutate) + if not check() then + return + end + if not value then + error(reason, 0) + end + elseif Gifts.ARITY[verb] then if nativeGift(ctx, row) == math.huge then return end diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 6ca6d6c33..528c5bfe7 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -2,6 +2,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local WorldEdits = require("src.mods.ScriptWorldEdits") Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 local ARITY = { set_flag = 2, @@ -24,9 +25,18 @@ end for verb, arity in pairs(Gifts.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(WorldEdits.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) - return type(verb) == "string" and (ARITY[aliases[verb] or verb] ~= nil) + if type(verb) ~= "string" then + return false + end + if WorldEdits.ARITY[verb] and require("src.core.GameVersion").generation() > 3 then + return false + end + return ARITY[aliases[verb] or verb] ~= nil end local function integer(n, lo, hi) return type(n) == "number" and n == n and n % 1 == 0 and n >= lo and n <= hi @@ -104,12 +114,19 @@ function Program.compile(raw, context) if not State.ARITY[verb] and not Gifts.ARITY[verb] + and not WorldEdits.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if Gifts.ARITY[verb] then + if WorldEdits.ARITY[verb] then + local value, reason = WorldEdits.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Gifts.ARITY[verb] then local value, reason = Gifts.syntax(verb, source) if not value then return fail(i, reason) diff --git a/src/mods/ScriptWorldEdits.lua b/src/mods/ScriptWorldEdits.lua new file mode 100644 index 000000000..193240e70 --- /dev/null +++ b/src/mods/ScriptWorldEdits.lua @@ -0,0 +1,85 @@ +-- Pure plans plus guarded native object/block effects inside an owned script. +local E = require("src.mods.Api3WorldEdits") +local Version = require("src.core.GameVersion") +local W = { ARITY = { show_object = 3, hide_object = 3, replace_block = 4 } } +local function id(value) + return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c") +end +local function integer(value) + return type(value) == "number" and value == value and value >= 0 and value < math.huge and value % 1 == 0 +end +function W.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable world edits unavailable for this generation" + end + if #row ~= W.ARITY[verb] then + return nil, "invalid command arity" + end + if verb == "replace_block" then + if not integer(row[2]) or not integer(row[3]) or not integer(row[4]) then + return nil, "invalid script block" + end + elseif not id(row[2]) or not (id(row[3]) or integer(row[3])) then + return nil, "invalid script object" + end + return { row[1], row[2], row[3], row[4] } +end +function W.prepare(game, world, owner, rows) + local needed = false + for _, row in ipairs(rows) do + needed = needed or W.ARITY[row[1]] ~= nil + end + if not needed then + return function() end + end + local gen = Version.generation() + local api = require("src.world.WorldAPI").new(game, owner) + local function plan(row) + if row[1] == "replace_block" then + return E.plan(game, api, gen, require, "replaceBlock", row[2], row[3], row[4]) + end + local mapId = row[2] + local maps = game.data.maps or {} + if gen == 3 and not maps[mapId] then + local prefix = Version.get() == "emerald" and "EM_" or "FR_" + if maps[prefix .. mapId] then + mapId = prefix .. mapId + end + end + local def = maps[mapId] + local found = false + for _, object in ipairs(def and def.objects or {}) do + if + object.name == row[3] + or object.id == row[3] + or object.index == row[3] + or object.localId == row[3] + then + found = true + break + end + end + if not found then + return nil, "native object unavailable: " .. tostring(row[3]) + end + return E.plan(game, api, gen, require, "toggleObject", mapId, row[3], row[1] == "show_object") + end + for i, row in ipairs(rows) do + if W.ARITY[row[1]] then + local operation, reason = plan(row) + if not operation then + return nil, "row " .. i .. ": " .. reason + end + end + end + return function(row, mutate) + local operation, reason = plan(row) + if not operation then + return nil, reason + end + return mutate(function() + return E.run(game, operation) + end) + end +end +return W diff --git a/tests/api3_contract/mods/script_world/main.lua b/tests/api3_contract/mods/script_world/main.lua new file mode 100644 index 000000000..56fd85ea7 --- /dev/null +++ b/tests/api3_contract/mods/script_world/main.lua @@ -0,0 +1,26 @@ +local mod = ... +local world = mod.world +local done, success = 0 +local events = {} +mod.events:on("world.object_toggled", function(ev) + events[#events + 1] = { kind = "object", mapId = ev.mapId, ref = ev.objName, visible = ev.visible } +end) +mod.events:on("world.block_replaced", function(ev) + events[#events + 1] = { kind = "block", mapId = ev.mapId, x = ev.bx, y = ev.by, value = ev.block } +end) +mod.exports.queue = function(rows) + return world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, events = events } +end +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.read = function(...) + return world:activeBlockAt(...) +end diff --git a/tests/api3_contract/mods/script_world/manifest.json b/tests/api3_contract/mods/script_world/manifest.json new file mode 100644 index 000000000..38990279c --- /dev/null +++ b/tests/api3_contract/mods/script_world/manifest.json @@ -0,0 +1 @@ +{"id":"script_world","name":"Portable world edits","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_world.lua b/tests/api3_contract/script_world.lua new file mode 100644 index 000000000..e52511375 --- /dev/null +++ b/tests/api3_contract/script_world.lua @@ -0,0 +1,66 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_world%.lua$") + local f = dofile(codeRoot .. "/world_edits.lua").fixture(root, backend, version, "script_world") + f.loader.game = f.game + f.ow.runner.update = function() end + if f.field then + function f.field.lock(token) + f.field._locks[token] = true + end + function f.field.unlock(token) + f.field._locks[token] = nil + end + end + if f.gen == 1 then + local native = f.ow.setMap + function f.ow:setMap(...) + local old = self.map + native(self, ...) + self.map = require("src.world.Map").new(old.def, old.tileset) + self.map.renderer = old.renderer + self.runner = require("src.script.ScriptRunner").new(f.game, self) + end + end + function f:tick() + if backend == "gen1" then + self.loader.hooks:call("core.update", function() end, self.game, 1 / 60) + else + self.loader.hooks:call("input.step", function() + self.ow.runner:update() + end, self.game, 1 / 60) + end + end + function f:drain() + for _ = 1, 100 do + if self.exports.result().done > 0 then + return + end + self:tick() + end + error("world edit script did not finish") + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "hide_object", f.home, "NPC" }, + { "show_object", f.home, "NPC" }, + { "replace_block", 0, 0, 8 }, + { "wait", 1 }, + { "set_flag", "mod:after" }, + })) + f:drain() + assert(f.exports.result().success == true and f.exports.get("mod:after") == true) + assert(f.visible.NPC == true and f.exports.read(f.home, 0, 0) == 8) + local report = + { events = f.exports.result().events, visible = f.visible.NPC, block = f.exports.read(f.home, 0, 0) } + f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "hide_object", f.home, "NPC" }, { "replace_block", 99, 0, 8 } }) + assert(value == nil and type(why) == "string" and f.visible.NPC and #f.exports.result().events == 0) + report.preflight = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 0b9872292..61ab12874 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -19,11 +19,14 @@ assert( or mode == "script_flags" or mode == "script_state" or mode == "script_gifts" + or mode == "script_world" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_gifts" then +if mode == "script_world" then + dofile(codeRoot .. "/script_world.lua").run(root, backend, version) +elseif mode == "script_gifts" then dofile(codeRoot .. "/script_gifts.lua").run(root, backend, version) elseif mode == "script_state" then dofile(codeRoot .. "/script_state.lua").run(root, backend, version) diff --git a/tests/api3_contract/world_edits.lua b/tests/api3_contract/world_edits.lua index 7d77c555b..8f7527a30 100644 --- a/tests/api3_contract/world_edits.lua +++ b/tests/api3_contract/world_edits.lua @@ -1,9 +1,9 @@ -- Real loaders, adapters and host commands, with native-shaped map/render seams. local M = {} local unpack = table.unpack or unpack -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/world_edits%.lua$") - local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "edits") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "edits") local Runtime = require("src.mods.Runtime") local ow, game = f.ow, f.game local def = game.data.maps[f.home] diff --git a/tests/engine/portable_script_world.lua b/tests/engine/portable_script_world.lua new file mode 100644 index 000000000..0faea304c --- /dev/null +++ b/tests/engine/portable_script_world.lua @@ -0,0 +1,117 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_world") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared world script contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "hide_object", "HOME", "NPC" }, { "show_object", "HOME", "MISSING" } }, + { { "hide_object", "HOME", "NPC" }, { "replace_block", 99, 0, 8 } }, + { { "replace_block", 0, 0, 9 } }, + { { "replace_block", -1, 0, 8 } }, + { { "replace_block", 0, 0, 256 } }, + { { "show_object", "HOME", false } }, + { { "show_object", "MISSING", "NPC" } }, + { { "hide_object", "HOME", "NPC", true } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid list refused before prefix") + T.eq(f.visible.NPC, true, "object unchanged after refusal") + T.eq(f.rebuilds + f.reloads + f.syncs, 0, "no native effects on refusal") +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, target in ipairs({ "object", "block" }) do + local f = fixture(version) + local row = target == "object" and { "hide_object", f.home, "NPC" } or { "replace_block", 0, 0, 8 } + T.check(f.exports.queue({ { "wait", 1 }, row, { "set_flag", "mod:after" } }), "world script queued") + local overlay = {} + f.game.stack.states[2] = overlay + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.result().done, 0, "foreign overlay pauses native edit") + T.eq(#f.exports.result().events, 0, "paused edit emits nothing") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.result().success, true, "edit resumes after overlay") + T.eq(f.exports.get("mod:after"), true, "script continues after native edit") + T.eq(#f.exports.result().events, 1, "single native event") + end +end +for _, field in ipairs({ "objectToggles", "flags" }) do + local f = fixture() + T.check( + f.exports.queue({ { "wait", 1 }, { "hide_object", f.home, "NPC" } }), + "deferred object edit admitted" + ) + f.game.save[field] = false + f:drain() + T.eq(f.exports.result().success, false, "live malformed store refuses edit") + T.eq(f.visible.NPC, true, "malformed live store has no write") +end +do + local f = fixture() + T.check(f.exports.queue({ { "wait", 1 }, { "replace_block", 0, 0, 8 } }), "deferred block edit admitted") + f.ow.map.renderer = nil + f:drain() + T.eq(f.exports.result().success, false, "live missing renderer refuses edit") + T.eq(f.rebuilds, 0, "no partial replacement") +end +for _, change in ipairs({ "rollback", "save", "map", "runner" }) do + local f = fixture() + local original = f.ow.runner + T.check(f.exports.queue({ { "wait", 1 }, { "hide_object", f.home, "NPC" } }), "owned edit admitted") + local replacement + if change == "rollback" then + f.loader:_rollback("script_world") + elseif change == "save" then + f.game.save = {} + elseif change == "map" then + f.ow.map = { id = f.home } + else + replacement = { update = function() end } + f.ow.runner = replacement + end + f:tick() + T.eq(f.exports.result().done, 1, "changed context cancels once") + T.eq(f.exports.result().success, false, "changed context fails") + T.eq(f.visible.NPC, true, "cancelled edit cannot run") + T.eq(f.ow.runner, replacement or original, "foreign or original runner preserved") +end +do + local f = fixture() + local original = f.ow.runner + f.loader.events:on("world.object_toggled", function() + f.loader:_rollback("script_world") + end, 0, "probe") + T.check( + f.exports.queue({ { "hide_object", f.home, "NPC" }, { "set_flag", "mod:after" } }), + "native event retires owner" + ) + T.eq(f.exports.result().done, 1, "event cancellation completes once") + T.eq(f.exports.result().success, false, "event cancellation reported") + T.check(f.ow.runner ~= original and not f.ow.runner:isRunning(), "reload's new idle runner retained") + T.eq(f.exports.get("mod:after"), false, "retired owner cannot execute following flag") +end +do + local f = fixture() + local old = f.ow.runner + T.check( + f.exports.queue({ { "hide_object", f.home, "NPC" }, { "wait", 2 }, { "set_flag", "mod:after" } }), + "native reload continuation admitted" + ) + f:drain() + T.eq(f.exports.result().success, true, "continuation survives native map and runner replacement") + T.check(f.ow.runner ~= old and not f.ow.runner:isRunning(), "destination idle runner restored") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script world edits") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index fe0fec4e6..f407c9168 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 4632d3a75be10e7af8161c715de0647b31dc1437 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:13:57 +0200 Subject: [PATCH 23/71] Add owned portable movement and facing scripts --- docs/api3-portability.md | 2 +- docs/portable-world-scripts.md | 26 +++ src/mods/PortableScripts.lua | 171 +++++++++++++++- src/mods/ScriptMovement.lua | 178 +++++++++++++++++ src/mods/ScriptProgram.lua | 17 +- .../api3_contract/mods/script_motion/main.lua | 15 ++ .../mods/script_motion/manifest.json | 1 + tests/api3_contract/npcs.lua | 4 +- tests/api3_contract/script_motion.lua | 108 ++++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_movement.lua | 189 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 12 files changed, 708 insertions(+), 10 deletions(-) create mode 100644 src/mods/ScriptMovement.lua create mode 100644 tests/api3_contract/mods/script_motion/main.lua create mode 100644 tests/api3_contract/mods/script_motion/manifest.json create mode 100644 tests/api3_contract/script_motion.lua create mode 100644 tests/engine/portable_script_movement.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 32258f6ae..312fea02b 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -135,7 +135,7 @@ loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. Eight object/block cases and eight NPC cases extend this to 96 comparisons -and 208 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +and 224 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index d245a750f..f98982763 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -300,3 +300,29 @@ restores the new idle runner when finished. Foreign overlays pause the program; unloading, changed saves/maps and invalid live stores cancel it. Edits already committed before cancellation are retained, and public world events follow the native edit behavior. + +## Movement and facing + +On Gen1–3, API 3 accepts `move_player(dir, tiles?)`, +`move_npc(ref, dir, tiles?)`, `move_npc_to(ref, x, y)`, +`place_npc(ref, x, y, facing?)`, `face_object(ref, dir, {hold=frames}?)`, +`face_player_dir(dir)`, `face(dir)`, `face_npc()` and `face_player()`. +`face` turns the talking NPC; `face_npc` turns the player toward that NPC, +and `face_player` turns that NPC toward the player. The last three require +an existing talking-NPC context. Object references resolve existing native +actors on the active map. Counts are 0–1024 cells (default 1). + +All target references and argument shapes are checked before any command +runs. Paths are computed against the live walkable map at dispatch, using +a bounded search; an unavailable path fails the admitted program. Steps +use the native movement queue and walker updates. Each completion is tied +to its exact run, step and actor. Movement only permits its own expected +player position changes. Callbacks are one-shot, and continuation waits +for a foreground logic frame, so foreign overlays pause later effects. + +Cancellation removes only the run's exact queued entries and leaves foreign +tracks intact. Invalid sessions/actors, refused native steps and a stalled +step (3,600 foreground logic frames) terminate the script. A native step +which crosses into a different map ends this run; use explicit `warp` for +portable cross-map scripts when supported. These limits are intentionally +reported rather than silently claiming arbitrary native movement parity. diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index fa78194bd..eee0a639a 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -5,6 +5,7 @@ local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Movement = require("src.mods.ScriptMovement") local WorldEdits = require("src.mods.ScriptWorldEdits") local GameVersion = require("src.core.GameVersion") local Scripts = {} @@ -246,6 +247,10 @@ local function facade(loader, modId, native) if not edit then return nil, editReason end + local movement, moveReason = Movement.prepare(ow, program) + if not movement then + return nil, moveReason + end local Runner = require("src.script.ScriptRunner") local runner = Runner.new(game, ow) local token = { @@ -285,9 +290,9 @@ local function facade(loader, modId, native) or not token.mutating and ow.map ~= token.map or ow.map.id ~= token.mapId or ow.player ~= token.player - or ow.player.cellX ~= token.x - or ow.player.cellY ~= token.y + or (ow.player.cellX ~= token.x or ow.player.cellY ~= token.y) and not (token.move and token.move.position()) or not token.mutating and ow.runner ~= runner + or token.move and not token.move.live() or GameVersion.get() ~= token.version or Runtime.hooks ~= loader.hooks or Runtime.events ~= loader.events @@ -304,6 +309,9 @@ local function facade(loader, modId, native) return end token.done = true + if token.move then + token.move.cancel() + end runner.co, runner.waitingFrames, runner.waitingCheck = nil, nil, nil -- Remove only this run's exact native screens, preserving foreign -- overlays and a replacement world's runner/locks. @@ -534,6 +542,161 @@ local function facade(loader, modId, native) game.stack:push(menu) runner:yield() end + local function move(ctx, row) + local actor = movement.target(row) + if not actor then + error("native movement target unavailable", 0) + end + local verb, dirs = row[1], {} + if verb == "move_player" or verb == "move_npc" then + local at = verb == "move_player" and 2 or 3 + for i = 1, row[at + 1] or 1 do + dirs[i] = row[at] + end + elseif verb == "move_npc_to" then + local reason + dirs, reason = Movement.path(ow, actor, row[3], row[4]) + if not dirs then + error(reason, 0) + end + elseif verb == "place_npc" then + if actor.def and actor.def.index then + require("src.script.Commands").place_npc(ctx, actor.def.index, row[3], row[4], row[5]) + return + end + actor.cellX, actor.cellY, actor.px, actor.py = row[3], row[4], row[3] * 16, row[4] * 16 + actor.moving, actor.marching, actor.progress = false, false, 0 + actor.targetX, actor.targetY = nil, nil + if row[5] then + actor.facing = row[5] + end + return + else + local dir = verb == "face_object" and row[3] or row[2] + if verb == "face_npc" or verb == "face_player" then + local other = verb == "face_npc" and movement.npc or ow.player + local dx, dy = other.cellX - actor.cellX, other.cellY - actor.cellY + dir = math.abs(dx) > math.abs(dy) and (dx > 0 and "right" or "left") or (dy > 0 and "down" or "up") + end + if actor.scriptFace then + actor:scriptFace(dir) + else + actor.facing = dir + end + if verb == "face_object" and row[4] and row[4].hold then + actor.timer = row[4].hold + end + return + end + for _, dir in ipairs(dirs) do + if not check() then + return + end + if actor ~= ow.player and movement.target(row) ~= actor then + error("movement target changed", 0) + end + local step, completed, accepted, fired, pending = token.step, false, nil, false, false + local sx, sy = actor.cellX, actor.cellY + local delta = Movement.directions[dir] + local tx, ty = sx + delta[1], sy + delta[2] + local entries, before = {}, {} + for _, entry in ipairs(ow.scriptMoves or {}) do + before[entry] = true + end + local lease = {} + function lease.live() + return movement.target(row) == actor + and (actor.cellX == sx and actor.cellY == sy or actor.cellX == tx and actor.cellY == ty) + end + function lease.position() + if actor ~= ow.player then + return false + end + local x, y = actor.cellX, actor.cellY + return (x == sx and y == sy or x == tx and y == ty) + end + function lease.cancel() + local removed = false + for i = #(ow.scriptMoves or {}), 1, -1 do + if entries[ow.scriptMoves[i]] then + table.remove(ow.scriptMoves, i) + removed = true + end + end + local foreign = false + for _, entry in ipairs(ow.scriptMoves or {}) do + if entry.entity == actor then + foreign = true + end + end + if removed and not foreign then + actor.moving, actor.marching = false, false + actor.targetX, actor.targetY = nil, nil + actor.px, actor.py = actor.cellX * 16, actor.cellY * 16 + end + if token.move == lease then + token.move = nil + end + end + local function done() + if fired then + return + end + if accepted == nil then + pending = true + return + end + fired = true + if not accepted or token.step ~= step or not check() then + return + end + if actor.cellX ~= tx or actor.cellY ~= ty then + token.finish(false, "native movement did not arrive") + return + end + completed = true + if actor == ow.player then + token.x, token.y = tx, ty + end + lease.cancel() + -- Poll completion on the next foreground logic frame. A callback + -- beneath a foreign overlay cannot resume later script effects. + end + token.move = lease + local called, value, reason = engine(ow.scriptMove, ow, actor, dir, 1, done) + for _, entry in ipairs(ow.scriptMoves or {}) do + if not before[entry] and entry.entity == actor then + entries[entry] = true + end + end + if token.done then + lease.cancel() + return + end + accepted = called and value ~= false and not (value == nil and reason ~= nil) + if not accepted then + lease.cancel() + error(called and (reason or "native movement refused") or message(value), 0) + end + if pending then + done() + end + if not check() then + return + end + if not completed then + local remaining = 3600 + runner.waitingCheck = function() + remaining = remaining - 1 + if remaining <= 0 then + token.finish(false, "native movement timed out") + end + return completed + end + runner:yield() + end + end + end function runner:exec(compiled, ctx) local pc, slice = 1, 0 while pc <= #compiled do @@ -557,7 +720,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if WorldEdits.ARITY[verb] then + if Movement.ARITY[verb] then + move(ctx, row) + elseif WorldEdits.ARITY[verb] then local value, reason = edit(row, mutate) if not check() then return diff --git a/src/mods/ScriptMovement.lua b/src/mods/ScriptMovement.lua new file mode 100644 index 000000000..792f39b94 --- /dev/null +++ b/src/mods/ScriptMovement.lua @@ -0,0 +1,178 @@ +-- Closed movement vocabulary over this host's native actor/track services. +local Version = require("src.core.GameVersion") +local unpack = table.unpack or unpack +local M = { + ARITY = { + move_player = 3, + move_npc = 4, + move_npc_to = 4, + place_npc = 5, + face = 2, + face_object = 4, + face_npc = 1, + face_player = 1, + face_player_dir = 2, + }, +} +local directions = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } +local function integer(v, max) + return type(v) == "number" and v == v and v >= 0 and v <= max and v % 1 == 0 +end +local function ref(v) + return type(v) == "string" and #v > 0 and #v <= 256 and not v:find("%c") or integer(v, 1000000) +end +function M.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable movement unavailable for this generation" + end + local n = #row + local minimum = ( + verb == "move_player" and 2 + or verb == "move_npc" and 3 + or verb == "place_npc" and 4 + or verb == "face_object" and 3 + ) or M.ARITY[verb] + if n < minimum or n > M.ARITY[verb] then + return nil, "invalid command arity" + end + if verb == "move_player" or verb == "move_npc" then + local at = verb == "move_player" and 2 or 3 + if + not directions[row[at]] + or row[at + 1] ~= nil and not integer(row[at + 1], 1024) + or at == 3 and not ref(row[2]) + then + return nil, "invalid script movement" + end + elseif verb == "place_npc" or verb == "move_npc_to" then + if + not ref(row[2]) + or not integer(row[3], 1000000) + or not integer(row[4], 1000000) + or row[5] ~= nil and not directions[row[5]] + then + return nil, "invalid NPC position" + end + elseif verb == "face_object" then + if not ref(row[2]) or not directions[row[3]] then + return nil, "invalid object facing" + end + elseif verb == "face" or verb == "face_player_dir" then + if not directions[row[2]] then + return nil, "invalid facing" + end + end + local out = { row[1], row[2], row[3], row[4], row[5] } + if verb == "face_object" and row[4] ~= nil then + if type(row[4]) ~= "table" or getmetatable(row[4]) ~= nil then + return nil, "invalid facing options" + end + out[4] = {} + for k, v in pairs(row[4]) do + if k ~= "hold" or not integer(v, 36000) then + return nil, "invalid facing options" + end + out[4][k] = v + end + end + return out +end +function M.actor(world, reference) + if reference == "player" then + return world.player + end + for _, actor in ipairs(world.npcs or {}) do + local def = actor.def or {} + if + actor.id == reference + or def.name == reference + or def.id == reference + or def.index == reference + or def.localId == reference + then + return actor + end + end +end +function M.prepare(world, rows) + local npc = world.talkNpc or world.runner and world.runner.ctx and world.runner.ctx.npc + local function target(row) + local verb = row[1] + if verb == "move_player" or verb == "face_player_dir" or verb == "face_npc" then + return world.player + end + if verb == "face" or verb == "face_player" then + return npc + end + return M.actor(world, row[2]) + end + for i, row in ipairs(rows) do + if M.ARITY[row[1]] then + local actor = target(row) + if not actor then + return nil, "row " .. i .. ": native movement target unavailable" + end + if (row[1] == "face_npc" or row[1] == "face_player") and not npc then + return nil, "row " .. i .. ": script NPC unavailable" + end + if not integer(actor.cellX, 1000000) or not integer(actor.cellY, 1000000) then + return nil, "invalid actor position" + end + if row[1]:match("^move_") and type(world.scriptMove) ~= "function" then + return nil, "native movement unavailable" + end + if + row[1] == "move_npc_to" + and (type(world.map.inBounds) ~= "function" or type(world.map.isWalkableCell) ~= "function") + then + return nil, "native path unavailable" + end + end + end + return { target = target, npc = npc } +end +function M.path(world, actor, tx, ty) + -- The native Gen1 command routes around occupied cells; later generations + -- use their walkable grid. Bound the search independently of map dimensions. + local map, gen = world.map, Version.generation() + local function key(x, y) + return x .. ":" .. y + end + local queue, first = { { actor.cellX, actor.cellY } }, 1 + local prev = { [key(actor.cellX, actor.cellY)] = false } + local dirs = { "up", "down", "left", "right" } + while queue[first] and first <= 16384 do + local x, y = unpack(queue[first]) + first = first + 1 + if x == tx and y == ty then + local path, at = {}, key(x, y) + while prev[at] do + table.insert(path, 1, prev[at][2]) + at = prev[at][1] + end + return path + end + for _, dir in ipairs(dirs) do + local d = directions[dir] + local nx, ny = x + d[1], y + d[2] + local k = key(nx, ny) + local goal = nx == tx and ny == ty + if + prev[k] == nil + and map:inBounds(nx, ny) + and (map:isWalkableCell(nx, ny) or gen == 1 and goal) + and ( + gen ~= 1 + or goal + or not require("src.world.Collision").occupied(world.entities or {}, nx, ny, actor) + ) + then + prev[k] = { key(x, y), dir } + queue[#queue + 1] = { nx, ny } + end + end + end + return nil, "native movement path unavailable" +end +M.directions = directions +return M diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 528c5bfe7..62e4e7eab 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -2,6 +2,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Movement = require("src.mods.ScriptMovement") local WorldEdits = require("src.mods.ScriptWorldEdits") Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 local ARITY = { @@ -28,12 +29,17 @@ end for verb, arity in pairs(WorldEdits.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(Movement.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) if type(verb) ~= "string" then return false end - if WorldEdits.ARITY[verb] and require("src.core.GameVersion").generation() > 3 then + if + (WorldEdits.ARITY[verb] or Movement.ARITY[verb]) and require("src.core.GameVersion").generation() > 3 + then return false end return ARITY[aliases[verb] or verb] ~= nil @@ -115,12 +121,19 @@ function Program.compile(raw, context) not State.ARITY[verb] and not Gifts.ARITY[verb] and not WorldEdits.ARITY[verb] + and not Movement.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if WorldEdits.ARITY[verb] then + if Movement.ARITY[verb] then + local value, reason = Movement.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif WorldEdits.ARITY[verb] then local value, reason = WorldEdits.syntax(verb, source, context) if not value then return fail(i, reason) diff --git a/tests/api3_contract/mods/script_motion/main.lua b/tests/api3_contract/mods/script_motion/main.lua new file mode 100644 index 000000000..be627b337 --- /dev/null +++ b/tests/api3_contract/mods/script_motion/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_motion/manifest.json b/tests/api3_contract/mods/script_motion/manifest.json new file mode 100644 index 000000000..f6452d302 --- /dev/null +++ b/tests/api3_contract/mods/script_motion/manifest.json @@ -0,0 +1 @@ +{"id":"script_motion","name":"Portable motion","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/npcs.lua b/tests/api3_contract/npcs.lua index 7ee2a243f..1e3170249 100644 --- a/tests/api3_contract/npcs.lua +++ b/tests/api3_contract/npcs.lua @@ -1,9 +1,9 @@ -- Real loaders/native public adapters with native actor/map/timing seams. local M = {} local unpack = table.unpack or unpack -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/npcs%.lua$") - local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "npcs") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "npcs") local Runtime = require("src.mods.Runtime") love = love or require("tests.love_stub") -- Render construction has no bearing on the public state contract. diff --git a/tests/api3_contract/script_motion.lua b/tests/api3_contract/script_motion.lua new file mode 100644 index 000000000..ce3ca68e3 --- /dev/null +++ b/tests/api3_contract/script_motion.lua @@ -0,0 +1,108 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_motion%.lua$") + local f = dofile(codeRoot .. "/npcs.lua").fixture(root, backend, version, "script_motion") + f.loader.game = f.game + f.ow.runner.update = function() end + function f.ow:npcByIndex(id) + return id == 1 and f.imported + end + function f.ow:objectEntity(id) + return id == 0 and f.player or id == 1 and f.imported + end + if f.field then + function f.field.lock(token) + f.field._locks[token] = true + end + function f.field.unlock(token) + f.field._locks[token] = nil + end + end + if backend == "gen1" and f.gen == 2 then + function f.ow:beginMovement(id, bytes, callback) + local dir = ({ [0x0C] = "down", [0x0D] = "up", [0x0E] = "left", [0x0F] = "right" })[bytes[1]] + return f:begin(id == 0 and f.player or f.imported, "move", assert(dir), #bytes - 1, callback) + end + function f.ow:moveObject(id, x, y) + local actor = assert(self:objectEntity(id)) + actor.cellX, actor.cellY = x, y + end + elseif backend == "gen1" and f.gen == 3 then + local objects = require("src.core.game3.objects") + objects._defs = { f.imported.def } + objects.find = function(id) + return id == 0xFF and f.player or f.byId[id] + end + objects.setObjectXY = function(id, x, y) + local actor = assert(objects.find(id)) + actor.cellX, actor.cellY = x, y + end + objects.applyMovement = function(id, bytes, callback) + local dir = ({ [0x10] = "down", [0x11] = "up", [0x12] = "left", [0x13] = "right" })[bytes[1]] + return f:begin(assert(objects.find(id)), "move", assert(dir), #bytes - 1, callback) + end + local collision = require("src.core.game3.collision") + collision.inBounds = function(x, y) + return x >= 0 and y >= 0 and x < 8 and y < 8 + end + collision.isWalkable = function() + return true + end + end + function f:tick() + local hook = backend == "gen1" and "core.update" or "input.step" + self.loader.hooks:call(hook, function() + if backend == "gen2" then + self.ow.runner:update() + end + end, self.game, 1 / 60) + end + function f:drain() + for _ = 1, 100 do + if self.exports.result().done > 0 then + return + end + if self.pending and self.pending.actor.moving then + self:complete() + end + self:tick() + end + error("movement script did not finish") + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "face_player_dir", "up" }, + { "move_player", "left", 2 }, + { "place_npc", 1, 2, 2, "left" }, + { "move_npc", 1, "right", 2 }, + { "move_npc_to", 1, 4, 4 }, + { "face_object", 1, "up" }, + { "set_flag", "mod:after" }, + })) + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + assert( + f.player.cellX == 5 + and f.player.cellY == 7 + and f.imported.cellX == 4 + and f.imported.cellY == 4 + and f.imported.facing == "up" + ) + local report = { + player = { f.player.cellX, f.player.cellY }, + npc = { f.imported.cellX, f.imported.cellY, f.imported.facing }, + complete = true, + } + f.restoreRenderer() + f = M.fixture(root, backend, version) + local ok, why = f.exports.queue({ { "face_player_dir", "up" }, { "move_npc", 99, "left", 1 } }) + assert(ok == nil and type(why) == "string" and f.player.facing == "left") + report.preflight = true + f.restoreRenderer() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 61ab12874..4fa181395 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -20,11 +20,14 @@ assert( or mode == "script_state" or mode == "script_gifts" or mode == "script_world" + or mode == "script_motion" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_world" then +if mode == "script_motion" then + dofile(codeRoot .. "/script_motion.lua").run(root, backend, version) +elseif mode == "script_world" then dofile(codeRoot .. "/script_world.lua").run(root, backend, version) elseif mode == "script_gifts" then dofile(codeRoot .. "/script_gifts.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_movement.lua b/tests/engine/portable_script_movement.lua new file mode 100644 index 000000000..3cf4e8581 --- /dev/null +++ b/tests/engine/portable_script_movement.lua @@ -0,0 +1,189 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_motion") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +local function dispose(f) + f.restoreRenderer() +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " motion contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "face_player_dir", "up" }, { "move_npc", 99, "down" } }, + { { "move_player", "north" } }, + { { "move_player", "down", -1 } }, + { { "move_player", "down", 1025 } }, + { { "move_npc_to", 1, 0.5, 0 } }, + { { "place_npc", 1, 0, 0, "north" } }, + { { "place_npc", 1, 0, math.huge } }, + { { "face_object", 1, "down", { hold = -1 } } }, + { { "face_object", 1, "down", { other = 1 } } }, + { { "face", "down" } }, + { { "face_player" } }, + { { "face_npc" } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid later command preflight") + T.eq(f.player.facing, "left", "invalid prefix does not turn player") + T.eq(#f.ow.scriptMoves, 0, "preflight creates no native track") + dispose(f) +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, kind in ipairs({ "player", "npc" }) do + local f = fixture(version) + local actor = kind == "player" and f.player or f.imported + local row = kind == "player" and { "move_player", "left", 2 } or { "move_npc", 1, "left", 2 } + local x = actor.cellX + T.check(f.exports.queue({ row, { "set_flag", "mod:after" } }), "two-step movement admitted") + local old = f.pending.callback + f:complete() + local overlay = {} + f.game.stack.states[2] = overlay + old() + old() + for _ = 1, 4 do + f:tick() + end + T.eq(actor.cellX, x - 1, "overlay pauses next owned step") + T.eq(f.exports.result().done, 0, "old callback cannot skip movement") + f.game.stack.states[2] = nil + f:drain() + T.eq(actor.cellX, x - 2, "two native steps completed once") + T.eq(f.exports.result().success, true, "native continuation completes") + T.eq(f.exports.get("mod:after"), true, "only finished walk resumes script") + dispose(f) + end +end +for _, action in ipairs({ "cancel", "rollback", "save", "map", "actor" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "move_npc", 1, "left", 2 }, { "set_flag", "mod:after" } }), + "cancellable track admitted" + ) + local callback = f.pending.callback + local foreign = { entity = f.imported, dir = "up", remaining = 1, onDone = function() end } + f.ow.scriptMoves[#f.ow.scriptMoves + 1] = foreign + if action == "cancel" then + require("src.mods.PortableScripts").cancelAll(f.loader, "script_motion", "probe cancellation") + elseif action == "rollback" then + f.loader:_rollback("script_motion") + elseif action == "save" then + f.game.save = {} + elseif action == "map" then + f.ow.map = { id = f.home } + else + f.ow.npcs = {} + end + f:tick() + T.eq(f.exports.result().done, 1, "retired movement completes once") + T.eq(f.exports.result().success, false, "retirement fails script") + T.eq(#f.ow.scriptMoves, 1, "only owned track removed") + T.eq(f.ow.scriptMoves[1], foreign, "foreign actor track retained") + callback() + callback() + T.eq(f.exports.result().done, 1, "retained callback cannot replay") + T.eq(f.ow.runner, original, "original runner restored") + dispose(f) +end +do + local f = fixture() + f.synchronous = true + T.check( + f.exports.queue({ { "move_player", "left", 2 }, { "set_flag", "mod:after" } }), + "synchronous native steps admitted" + ) + T.eq(f.exports.result().success, true, "synchronous callback completes without yielding forever") + T.eq(f.player.cellX, 5, "synchronous two-step position") + dispose(f) +end +for _, failure in ipairs({ "refuse", "throw", "unload" }) do + local f = fixture() + local native = f.ow.scriptMove + function f.ow:scriptMove(...) + native(self, ...) + if failure == "refuse" then + return false, "probe refusal" + elseif failure == "throw" then + error("probe native error") + else + f.loader:_rollback("script_motion") + end + end + T.check( + f.exports.queue({ { "move_player", "left", 2 }, { "set_flag", "mod:after" } }), + "native failure after admission" + ) + T.eq(f.exports.result().success, false, "native failure reported") + T.eq(#f.ow.scriptMoves, 0, "late native track cleaned") + T.eq(f.player.moving, false, "owned actor unlocked") + dispose(f) +end +do + local f = fixture() + f.ow.talkNpc = f.imported + T.check( + f.exports.queue({ + { "face", "left" }, + { "face_npc" }, + { "face_player" }, + { + "face_object", + 1, + "up", + { hold = 20 }, + }, + }), + "context facing admitted" + ) + T.eq(f.exports.result().success, true, "context facing completes") + T.eq(f.player.facing, "up", "player faces talking NPC") + T.eq(f.imported.facing, "up", "arbitrary object explicit direction") + T.eq(f.imported.timer, 20, "bounded facing hold carried") + dispose(f) +end +do + local f = fixture() + T.check(f.exports.queue({ { "move_player", "left" } }), "stalled native track admitted") + for _ = 1, 3601 do + f:tick() + end + T.eq(f.exports.result().success, false, "stalled track times out") + T.eq(#f.ow.scriptMoves, 0, "timeout removes owned track") + dispose(f) +end +-- Borrow the actual production movement queue and both walker updates. +local OW = require("src.world.OverworldController") +local Player = require("src.world.Player") +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + f.ow.scriptMove = OW.scriptMove + f.imported.frozen = true + f.player.turnTimer, f.player.progress = 0, 0 + T.check( + f.exports.queue({ { "move_player", "left", 2 }, { "move_npc", 1, "up", 2 }, { "set_flag", "mod:after" } }), + version .. " native queues admitted" + ) + for _ = 1, 100 do + OW.updateScriptMoves(f.ow) + Player.update(f.player) + f.imported:update(f.ow.map, f.ow.entities) + f:tick() + end + T.eq(f.player.cellX, 5, "actual player updater walks exactly two cells") + T.eq(f.imported.cellY, 4, "actual NPC updater walks exactly two cells") + T.eq(#f.ow.scriptMoves, 0, "actual native tracks released") + T.eq(f.exports.result().success, true, "actual native movement resumes script") + T.eq(f.exports.get("mod:after"), true, "native walk continuation runs once") + dispose(f) +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script movement") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index f407c9168..de26d3014 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 7e620268d09f78d8fc01664ba2329eb6e3936763 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:25:28 +0200 Subject: [PATCH 24/71] Add portable native travel with destination-aware preflight --- docs/api3-portability.md | 2 +- docs/portable-world-scripts.md | 27 ++ src/mods/Api3Warp.lua | 71 +++--- src/mods/PortableScripts.lua | 142 ++++++++++- src/mods/ScriptMovement.lua | 12 +- src/mods/ScriptProgram.lua | 16 +- src/mods/ScriptTravel.lua | 88 +++++++ src/mods/ScriptWorldEdits.lua | 4 +- .../api3_contract/mods/script_travel/main.lua | 15 ++ .../mods/script_travel/manifest.json | 1 + tests/api3_contract/npcs.lua | 1 + tests/api3_contract/script_motion.lua | 4 +- tests/api3_contract/script_travel.lua | 119 +++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_travel.lua | 232 ++++++++++++++++++ tests/engine/portable_world_scripts.lua | 3 +- tools/api3-contract-check/check.py | 2 +- 17 files changed, 694 insertions(+), 50 deletions(-) create mode 100644 src/mods/ScriptTravel.lua create mode 100644 tests/api3_contract/mods/script_travel/main.lua create mode 100644 tests/api3_contract/mods/script_travel/manifest.json create mode 100644 tests/api3_contract/script_travel.lua create mode 100644 tests/engine/portable_script_travel.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 312fea02b..b849bf91c 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -135,7 +135,7 @@ loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. Eight object/block cases and eight NPC cases extend this to 96 comparisons -and 224 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +and 240 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index f98982763..dc0820dc4 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -326,3 +326,30 @@ step (3,600 foreground logic frames) terminate the script. A native step which crosses into a different map ends this run; use explicit `warp` for portable cross-map scripts when supported. These limits are intentionally reported rather than silently claiming arbitrary native movement parity. + +## Explicit travel + +Gen1–3 accept `warp(mapId, x, y, facing?)` and +`remember_outdoor(mapId, x, y)`. Destination names, 0-based cell coordinates, +dimensions and facing use the public API-3 warp planner. `remember_outdoor` +sets the native outdoor/escape destination without travelling. + +Preflight follows the linear row list's map context, just as the reference +engine does. Object/block and movement rows after a warp are checked against +the destination's definitions; path/block geometry requires the destination +tileset to be available. These plans do not load a live map or run callbacks. +All dispatches still validate their actual live targets. Branches and native +entry scripts can change runtime conditions, so successful preflight is not +a guarantee that an admitted asynchronous program will complete. + +A warp uses the native transition and arrival bookkeeping. Its continuation +binds to the destination map and idle runner only after the native arrival +callback; it never resumes beneath a foreign overlay. A busy destination +runner is preserved and the portable run fails. Talking-NPC context is +cleared at arrival. One-shot callbacks, session checks and a 3,600-frame +native-transition/foreground deadline prevent late script effects. + +The transition belongs to the engine: unloading or timing out a portable +script leaves the already admitted native warp free to finish and release +its own locks. It suppresses the script continuation, rather than undoing +committed native travel or removing the engine's transition screen. diff --git a/src/mods/Api3Warp.lua b/src/mods/Api3Warp.lua index 400465d87..c447cdec1 100644 --- a/src/mods/Api3Warp.lua +++ b/src/mods/Api3Warp.lua @@ -84,6 +84,44 @@ local function observe(loader) end, math.huge, "engine:api3_warp") end end +-- Pure destination validation shared by public warps and portable scripts. +function W.plan(game, ow, generation, mapId, x, y, facing) + if type(mapId) ~= "string" or #mapId == 0 or #mapId > 256 or mapId:find("%c") then + return nil, "invalid map id" + end + local maps = game.data and game.data.maps + local destination = mapId + if generation == 3 and maps and not maps[destination] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if maps[prefix .. destination] then + destination = prefix .. destination + end + end + local def = maps and maps[destination] + if not def then + return nil, "unknown map: " .. mapId + end + if not integer(x) or not integer(y) then + return nil, "invalid coordinates" + end + local scale = generation == 3 and 1 or 2 + if not integer(def.width) or def.width == 0 or not integer(def.height) or def.height == 0 then + return nil, "invalid map dimensions" + end + if x >= def.width * scale or y >= def.height * scale then + return nil, "coordinates out of bounds" + end + if facing ~= nil and not dirs[facing] then + return nil, "invalid facing" + end + return { + mapId = destination, + x = x, + y = y, + facing = facing or (generation ~= 1 and ow.player.facing) or "down", + def = def, + } +end function W.new(loader, owner, generation, current, native) local context = contexts[loader] if not context then @@ -117,34 +155,11 @@ function W.new(loader, owner, generation, current, native) if pending[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then return nil, "world is busy" end - if type(mapId) ~= "string" or #mapId == 0 or #mapId > 256 or mapId:find("%c") then - return nil, "invalid map id" - end - local maps = game.data and game.data.maps - local destination = mapId - if generation == 3 and maps and not maps[destination] then - local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" - if maps[prefix .. destination] then - destination = prefix .. destination - end - end - local def = maps and maps[destination] - if not def then - return nil, "unknown map: " .. mapId - end - if not integer(x) or not integer(y) then - return nil, "invalid coordinates" - end - local scale = generation == 3 and 1 or 2 - if not integer(def.width) or def.width == 0 or not integer(def.height) or def.height == 0 then - return nil, "invalid map dimensions" - end - if x >= def.width * scale or y >= def.height * scale then - return nil, "coordinates out of bounds" - end - if facing ~= nil and not dirs[facing] then - return nil, "invalid facing" + local plan, reason = W.plan(game, ow, generation, mapId, x, y, facing) + if not plan then + return nil, reason end + local destination = plan.mapId if options ~= nil and (type(options) ~= "table" or getmetatable(options) ~= nil) then return nil, "invalid options" end @@ -170,7 +185,7 @@ function W.new(loader, owner, generation, current, native) end opts[key] = value end - facing = facing or (generation ~= 1 and ow.player.facing) or "down" + facing = plan.facing local hooks, events = Runtime.hooks, Runtime.events local token = { game = game, loader = loader, owner = owner, destination = destination, callback = opts.onDone } diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index eee0a639a..fad11a146 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -5,6 +5,7 @@ local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Travel = require("src.mods.ScriptTravel") local Movement = require("src.mods.ScriptMovement") local WorldEdits = require("src.mods.ScriptWorldEdits") local GameVersion = require("src.core.GameVersion") @@ -143,6 +144,8 @@ local function observe(loader) token.finish(false, ok and why or "script context failed") elseif token.waitingUI and not contains(token.stack, token.waitingUI) then token.finish(false, "script UI was removed") + elseif token.travel then + token.travel.tick() end end return nextFn(game, dt) @@ -243,11 +246,15 @@ local function facade(loader, modId, native) if not gift then return nil, giftReason end - local edit, editReason = WorldEdits.prepare(game, ow, modId, program) + local travel, travelReason = Travel.prepare(game, ow, modId, program) + if not travel then + return nil, travelReason + end + local edit, editReason = WorldEdits.prepare(game, ow, modId, program, true) if not edit then return nil, editReason end - local movement, moveReason = Movement.prepare(ow, program) + local movement, moveReason = Movement.prepare(ow, program, true) if not movement then return nil, moveReason end @@ -287,18 +294,19 @@ local function facade(loader, modId, native) or game.stack.states ~= token.states or not contains(token.stack, ow) or game.overworld and game.overworld ~= ow - or not token.mutating and ow.map ~= token.map - or ow.map.id ~= token.mapId + or not token.travel and not token.mutating and ow.map ~= token.map + or not token.travel and ow.map.id ~= token.mapId or ow.player ~= token.player - or (ow.player.cellX ~= token.x or ow.player.cellY ~= token.y) and not (token.move and token.move.position()) - or not token.mutating and ow.runner ~= runner + or not token.travel and (ow.player.cellX ~= token.x or ow.player.cellY ~= token.y) and not (token.move and token.move.position()) + or not token.travel and not token.mutating and ow.runner ~= runner or token.move and not token.move.live() or GameVersion.get() ~= token.version or Runtime.hooks ~= loader.hooks or Runtime.events ~= loader.events or game.linkSession or game.linkNet and not game.linkNet.closed - or ow.transitioning + or not token.travel and ow.transitioning + or token.travel and not token.travel.valid() then return nil, "script session changed" end @@ -309,6 +317,9 @@ local function facade(loader, modId, native) return end token.done = true + if token.travel then + token.travel.retired = true + end if token.move then token.move.cancel() end @@ -413,7 +424,7 @@ local function facade(loader, modId, native) return state end discover = function(before, step) - if token.done or token.step ~= step then + if token.done or token.step ~= step or token.travel then return end for _, state in ipairs(game.stack.states) do @@ -542,6 +553,117 @@ local function facade(loader, modId, native) game.stack:push(menu) runner:yield() end + local function warp(ctx, row) + local plan, reason = travel(row) + if not plan then + error(reason, 0) + end + if row[1] == "remember_outdoor" then + token.save.lastOutdoor = { id = plan.mapId, x = plan.x, y = plan.y } + if GameVersion.generation() == 3 then + token.save.escapeWarp = { map = plan.mapId, warpId = 255, x = plan.x, y = plan.y } + elseif ow.rememberOutdoor then + ow:rememberOutdoor(plan.mapId, plan.x, plan.y) + else + ow.lastOutdoor = token.save.lastOutdoor + end + return + end + local origin, step = ow.map, token.step + local accepted, pending, fired, complete = nil, false, false, false + local lease = { ui = {} } + local remaining = 3600 + function lease.tick() + local top = token.stack:top() + if top == ow or lease.ui[top] then + remaining = remaining - 1 + if remaining <= 0 then + token.finish(false, "native warp timed out") + end + end + end + function lease.valid() + if lease.retired then + return false + end + if + ow.map == origin + and ow.player.cellX == token.x + and ow.player.cellY == token.y + and ow.runner == runner + then + return true + end + return ow.map and ow.map.id == plan.mapId and ow.player.cellX == plan.x and ow.player.cellY == plan.y + end + local function done() + if fired or lease.retired then + return + end + if accepted == nil then + pending = true + return + end + fired = true + if not accepted or token.step ~= step or not check() then + return + end + if + not ow.map + or ow.map.id ~= plan.mapId + or ow.player.cellX ~= plan.x + or ow.player.cellY ~= plan.y + or ow.transitioning + then + token.finish(false, "native warp did not arrive") + return + end + if ow.runner ~= runner then + if not ow.runner or ow.runner:isRunning() then + token.finish(false, "destination script is busy") + return + end + token.original = ow.runner + ow.runner = runner + end + token.map, token.mapId, token.x, token.y = ow.map, plan.mapId, plan.x, plan.y + ctx.mapId, ctx.npc = plan.mapId, nil + movement.forgetNpc() + movement.npc = nil + complete = true + end + token.travel = lease + local statesBefore = snapshot() + local called, value, why = + engine(ow.startWarpTo, ow, plan.mapId, plan.x, plan.y, plan.facing, done, { via = "warp" }) + for _, state in ipairs(game.stack.states) do + if not statesBefore[state] then + lease.ui[state] = true + end + end + accepted = called and value ~= false and not (value == nil and why ~= nil) + if not accepted then + lease.retired = true + token.travel = nil + error(called and (why or "native warp refused") or message(value), 0) + end + if pending then + done() + end + if not check() then + return + end + if not complete then + runner.waitingCheck = function() + return complete + end + runner:yield() + end + lease.retired = true + if token.travel == lease then + token.travel = nil + end + end local function move(ctx, row) local actor = movement.target(row) if not actor then @@ -720,7 +842,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if Movement.ARITY[verb] then + if Travel.ARITY[verb] then + warp(ctx, row) + elseif Movement.ARITY[verb] then move(ctx, row) elseif WorldEdits.ARITY[verb] then local value, reason = edit(row, mutate) diff --git a/src/mods/ScriptMovement.lua b/src/mods/ScriptMovement.lua index 792f39b94..a459f9ee7 100644 --- a/src/mods/ScriptMovement.lua +++ b/src/mods/ScriptMovement.lua @@ -94,7 +94,7 @@ function M.actor(world, reference) end end end -function M.prepare(world, rows) +function M.prepare(world, rows, prevalidated) local npc = world.talkNpc or world.runner and world.runner.ctx and world.runner.ctx.npc local function target(row) local verb = row[1] @@ -107,7 +107,7 @@ function M.prepare(world, rows) return M.actor(world, row[2]) end for i, row in ipairs(rows) do - if M.ARITY[row[1]] then + if not prevalidated and M.ARITY[row[1]] then local actor = target(row) if not actor then return nil, "row " .. i .. ": native movement target unavailable" @@ -129,7 +129,13 @@ function M.prepare(world, rows) end end end - return { target = target, npc = npc } + return { + target = target, + npc = npc, + forgetNpc = function() + npc = nil + end, + } end function M.path(world, actor, tx, ty) -- The native Gen1 command routes around occupied cells; later generations diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 62e4e7eab..1ccf08cd2 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -2,6 +2,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Travel = require("src.mods.ScriptTravel") local Movement = require("src.mods.ScriptMovement") local WorldEdits = require("src.mods.ScriptWorldEdits") Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 @@ -32,13 +33,17 @@ end for verb, arity in pairs(Movement.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(Travel.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) if type(verb) ~= "string" then return false end if - (WorldEdits.ARITY[verb] or Movement.ARITY[verb]) and require("src.core.GameVersion").generation() > 3 + (WorldEdits.ARITY[verb] or Movement.ARITY[verb] or Travel.ARITY[verb]) + and require("src.core.GameVersion").generation() > 3 then return false end @@ -122,12 +127,19 @@ function Program.compile(raw, context) and not Gifts.ARITY[verb] and not WorldEdits.ARITY[verb] and not Movement.ARITY[verb] + and not Travel.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if Movement.ARITY[verb] then + if Travel.ARITY[verb] then + local value, reason = Travel.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Movement.ARITY[verb] then local value, reason = Movement.syntax(verb, source, context) if not value then return fail(i, reason) diff --git a/src/mods/ScriptTravel.lua b/src/mods/ScriptTravel.lua new file mode 100644 index 000000000..31f56630c --- /dev/null +++ b/src/mods/ScriptTravel.lua @@ -0,0 +1,88 @@ +-- Destination preflight, including later world rows against destination data. +local Warp = require("src.mods.Api3Warp") +local Movement = require("src.mods.ScriptMovement") +local Edits = require("src.mods.ScriptWorldEdits") +local Version = require("src.core.GameVersion") +local copy = require("src.mods.Merge").deepCopy +local T = { ARITY = { warp = 5, remember_outdoor = 4 } } +function T.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable travel unavailable for this generation" + end + if #row < 4 or #row > T.ARITY[verb] then + return nil, "invalid command arity" + end + -- Destination names, coordinates, dimensions and facing are validated with + -- the same pure planner as the public API once a save/world is available. + return { row[1], row[2], row[3], row[4], row[5] } +end +local function future(game, world, plan, cache) + local tiles = game.data.tilesets and game.data.tilesets[plan.def.tileset] + local map = cache[plan.mapId] + if map then + elseif tiles then + map = require("src.world.Map").new(copy(plan.def), copy(tiles)) + else + map = { id = plan.mapId, def = plan.def } + end + map.renderer = { rebuild = function() end } + cache[plan.mapId] = map + local view = { + isOverworld = true, + map = map, + player = { cellX = plan.x, cellY = plan.y, facing = plan.facing }, + npcs = {}, + syncObjectVisibility = world.syncObjectVisibility, + scriptMove = world.scriptMove, + } + for _, def in ipairs(plan.def.objects or {}) do + view.npcs[#view.npcs + 1] = + { def = def, id = plan.mapId .. "_obj_" .. tostring(def.index), cellX = def.x, cellY = def.y } + end + local proxy = { + data = game.data, + save = game.save, + overworld = view, + stack = { + top = function() + return view + end, + states = { view }, + }, + } + return proxy, view +end +function T.prepare(game, world, owner, rows) + local gen = Version.generation() + local model, modelGame = world, game + local cache = {} + for i, row in ipairs(rows) do + local verb = row[1] + if T.ARITY[verb] then + local plan, why = Warp.plan(game, model, gen, row[2], row[3], row[4], row[5]) + if not plan then + return nil, "row " .. i .. ": " .. why + end + if verb == "warp" then + if type(world.startWarpTo) ~= "function" then + return nil, "native warp unavailable" + end + modelGame, model = future(game, world, plan, cache) + end + elseif Edits.ARITY[verb] then + local edit, why = Edits.prepare(modelGame, model, owner, { row }) + if not edit then + return nil, "row " .. i .. ": " .. why + end + elseif Movement.ARITY[verb] then + local movement, why = Movement.prepare(model, { row }) + if not movement then + return nil, "row " .. i .. ": " .. why + end + end + end + return function(row) + return Warp.plan(game, world, gen, row[2], row[3], row[4], row[5]) + end +end +return T diff --git a/src/mods/ScriptWorldEdits.lua b/src/mods/ScriptWorldEdits.lua index 193240e70..0c2c7ba9d 100644 --- a/src/mods/ScriptWorldEdits.lua +++ b/src/mods/ScriptWorldEdits.lua @@ -24,7 +24,7 @@ function W.syntax(verb, row, context) end return { row[1], row[2], row[3], row[4] } end -function W.prepare(game, world, owner, rows) +function W.prepare(game, world, owner, rows, prevalidated) local needed = false for _, row in ipairs(rows) do needed = needed or W.ARITY[row[1]] ~= nil @@ -65,7 +65,7 @@ function W.prepare(game, world, owner, rows) return E.plan(game, api, gen, require, "toggleObject", mapId, row[3], row[1] == "show_object") end for i, row in ipairs(rows) do - if W.ARITY[row[1]] then + if not prevalidated and W.ARITY[row[1]] then local operation, reason = plan(row) if not operation then return nil, "row " .. i .. ": " .. reason diff --git a/tests/api3_contract/mods/script_travel/main.lua b/tests/api3_contract/mods/script_travel/main.lua new file mode 100644 index 000000000..be627b337 --- /dev/null +++ b/tests/api3_contract/mods/script_travel/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_travel/manifest.json b/tests/api3_contract/mods/script_travel/manifest.json new file mode 100644 index 000000000..f07cf280a --- /dev/null +++ b/tests/api3_contract/mods/script_travel/manifest.json @@ -0,0 +1 @@ +{"id":"script_travel","name":"Portable travel","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/npcs.lua b/tests/api3_contract/npcs.lua index 1e3170249..888ad92e5 100644 --- a/tests/api3_contract/npcs.lua +++ b/tests/api3_contract/npcs.lua @@ -29,6 +29,7 @@ function M.fixture(root, backend, version, modId) blockCells = f.gen == 3 and 1 or 2, blockTiles = f.gen == 3 and 2 or 4, } + f.tiles = tiles for i = 1, 16 do tiles.blocks[2][i] = 0 end diff --git a/tests/api3_contract/script_motion.lua b/tests/api3_contract/script_motion.lua index ce3ca68e3..e4f215fc7 100644 --- a/tests/api3_contract/script_motion.lua +++ b/tests/api3_contract/script_motion.lua @@ -1,7 +1,7 @@ local M = {} -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_motion%.lua$") - local f = dofile(codeRoot .. "/npcs.lua").fixture(root, backend, version, "script_motion") + local f = dofile(codeRoot .. "/npcs.lua").fixture(root, backend, version, modId or "script_motion") f.loader.game = f.game f.ow.runner.update = function() end function f.ow:npcByIndex(id) diff --git a/tests/api3_contract/script_travel.lua b/tests/api3_contract/script_travel.lua new file mode 100644 index 000000000..4894ed053 --- /dev/null +++ b/tests/api3_contract/script_travel.lua @@ -0,0 +1,119 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_travel%.lua$") + local f = dofile(codeRoot .. "/script_motion.lua").fixture(root, backend, version, "script_travel") + f.game.data.tilesets = f.game.data.tilesets or {} + f.game.data.tilesets.FIX = f.tiles + for _, def in pairs(f.game.data.maps) do + if def.midLayout then + def.midLayout.width, def.midLayout.height = def.width, def.height + end + end + local dest = f.game.data.maps[f.dest] + dest.objects = { + { + index = 1, + localId = 1, + name = "IMPORTED", + sprite = "PROBE", + x = 4, + y = 4, + movement = "STAY", + range = "DOWN", + }, + } + local arrive = f.arrive + function f:arrive(id) + arrive(self, id) + local def = self.game.data.maps[id or self.dest] + if not def then + return + end + if backend == "gen1" and f.gen == 2 then + self.ow.map = require("src.world.gen2.Map").new(def, self.tiles) + elseif not (backend == "gen1" and f.gen == 3) then + self.ow.map = require("src.world.Map").new(def, self.tiles) + end + if backend == "gen1" and f.gen > 1 then + self.ow.runner = { + isRunning = function() + return false + end, + update = function() end, + } + else + self.ow.runner = require("src.script.ScriptRunner").new(self.game, self.ow) + end + self.imported.cellX, self.imported.cellY = 4, 4 + self.imported.def = def.objects[1] + if self.byId then + require("src.core.game3.objects")._defs = def.objects + end + end + function f:drain() + for _ = 1, 150 do + if self.exports.result().done > 0 then + return + end + if self.destination and self.ow.transitioning then + self:arrive() + self:completeWarp() + end + if self.pending and self.pending.actor.moving then + self:complete() + end + self:tick() + end + error("travel script did not finish") + end + -- npc fixture's complete belongs to movement; preserve the warp seam separately. + function f:completeWarp() + self.busy, self.ow.transitioning = false, false + if self.callback then + self.callback() + end + end + local dispatch = f.dispatch + function f:dispatch(...) + local movementComplete = self.complete + self.complete = self.completeWarp + local called, a, b = pcall(dispatch, self, ...) + self.complete = movementComplete + if not called then + error(a, 0) + end + return a, b + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local ok, why = f.exports.queue({ + { "remember_outdoor", f.home, 1, 1 }, + { "warp", f.dest, 2, 2, "right" }, + { "move_player", "left" }, + { "move_npc", 1, "down" }, + { "face_object", 1, "up" }, + { "set_flag", "mod:after" }, + }) + assert(ok, why) + assert(f.exports.result().done == 0) + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + assert(f.ow.map.id == f.dest and f.player.cellX == 1 and f.player.cellY == 2 and f.imported.cellY == 5) + local report = { + map = f.ow.map.id, + player = { f.player.cellX, f.player.cellY }, + npc = { f.imported.cellX, f.imported.cellY, f.imported.facing }, + complete = true, + } + f.restoreRenderer() + f = M.fixture(root, backend, version) + local value, reason = f.exports.queue({ { "face_player_dir", "up" }, { "warp", "UNKNOWN", 0, 0 } }) + assert(value == nil and type(reason) == "string" and f.player.facing == "left" and #f.trace == 0) + report.preflight = true + f.restoreRenderer() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 4fa181395..d69c8e8dc 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -21,11 +21,14 @@ assert( or mode == "script_gifts" or mode == "script_world" or mode == "script_motion" + or mode == "script_travel" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_motion" then +if mode == "script_travel" then + dofile(codeRoot .. "/script_travel.lua").run(root, backend, version) +elseif mode == "script_motion" then dofile(codeRoot .. "/script_motion.lua").run(root, backend, version) elseif mode == "script_world" then dofile(codeRoot .. "/script_world.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_travel.lua b/tests/engine/portable_script_travel.lua new file mode 100644 index 000000000..16397e435 --- /dev/null +++ b/tests/engine/portable_script_travel.lua @@ -0,0 +1,232 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_travel") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared travel: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "face_player_dir", "up" }, { "warp", "UNKNOWN", 0, 0 } }, + { { "warp", "DEST", 0.5, 0 } }, + { { "warp", "DEST", -1, 0 } }, + { { "warp", "DEST", 16, 0 } }, + { { "warp", "DEST", 0, 0, "north" } }, + { { "warp", "DEST", 0, 0, "down", {} } }, + { { "warp", "DEST", 0, 0 }, { "move_npc", 99, "down" } }, + { { "warp", "DEST", 0, 0 }, { "replace_block", 999, 0, 1 } }, + { { "warp", "DEST", 0, 0 }, { "face", "down" } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid destination/later row preflight") + T.eq(#f.trace, 0, "invalid suffix prevents native warp") + T.eq(f.player.facing, "left", "invalid list has no prefix effect") + f.restoreRenderer() +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + T.check(f.exports.queue({ { "remember_outdoor", f.home, 1, 2 } }), "outdoor memory admitted") + T.same(f.game.save.lastOutdoor, { id = f.home, x = 1, y = 2 }, "canonical remembered destination") + if f.gen == 3 then + T.same(f.game.save.escapeWarp, { map = f.home, warpId = 255, x = 1, y = 2 }, "native Gen3 escape memory") + end + f.restoreRenderer() +end +for _, change in ipairs({ "cancel", "rollback", "save", "data", "stack", "player", "version" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }), + "cancellable warp admitted" + ) + local callback = f.callback + if change == "cancel" then + require("src.mods.PortableScripts").cancelAll(f.loader, "script_travel") + elseif change == "rollback" then + f.loader:_rollback("script_travel") + elseif change == "version" then + Version.set("blue") + elseif change == "player" then + f.ow.player = { cellX = 2, cellY = 2 } + else + f.game[change] = {} + end + local ok = pcall(function() + f:tick() + end) + T.check(ok, "changed context checked without crashing") + T.eq(f.exports.result().done, 1, "changed context completes once") + T.eq(f.exports.result().success, false, "changed warp fails script") + callback() + callback() + T.eq(f.exports.result().done, 1, "stale warp callback cannot replay") + T.eq(f.ow.runner, original, "source idle runner restored") + f.restoreRenderer() +end +do + local f = fixture() + T.check(f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }), "warp before overlay") + local old = f.callback + f:arrive() + f:completeWarp() + local replacement = f.ow.runner + local overlay = {} + f.game.stack.states[2] = overlay + old() + old() + for _ = 1, 4 do + f:tick() + end + T.eq(f.exports.get("mod:after"), false, "arrival callback cannot run beneath foreign UI") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.result().success, true, "arrival resumes in foreground") + T.check(f.ow.runner ~= replacement and not f.ow.runner:isRunning(), "new destination idle runner restored") + f.restoreRenderer() +end +for _, failure in ipairs({ "throw", "refuse", "false_arrival", "wrong_map" }) do + local f = fixture() + if failure == "throw" or failure == "refuse" then + f.failure = failure + end + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }), + "native warp admitted before result" + ) + if failure == "false_arrival" then + f:completeWarp() + elseif failure == "wrong_map" then + f:arrive(f.home) + f:completeWarp() + end + f:tick() + T.eq(f.exports.result().success, false, "native failure cannot complete script") + T.eq(f.exports.get("mod:after"), false, "native failure cannot burn later flag") + f.restoreRenderer() +end +do + local f = fixture() + f.synchronous = true + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }), + "synchronous arrival admitted" + ) + T.eq(f.exports.result().success, true, "synchronous warp continuation does not hang") + f.restoreRenderer() +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + function f.ow:startWarpTo(id, x, y, facing, callback) + self.player.cellX, self.player.cellY, self.player.facing = x, y, facing + callback() + end + T.check( + f.exports.queue({ { "warp", f.home, 1, 1 }, { "set_flag", "mod:after" } }), + "same-map reused runtime admitted" + ) + T.eq(f.exports.result().success, true, "same-map warp can retain native map identity") + T.eq(f.exports.get("mod:after"), true, "same-map native callback resumes once") + f.restoreRenderer() +end +do + local f = fixture() + T.check(f.exports.queue({ { "warp", f.dest, 2, 2 } }), "stalled warp admitted") + for _ = 1, 3601 do + f:tick() + end + T.eq(f.exports.result().success, false, "warp deadline expires") + f:arrive() + f:completeWarp() + T.eq(f.ow.transitioning, false, "engine can still finish timed-out warp") + T.eq(f.exports.result().done, 1, "timed-out script cannot resume") + f.restoreRenderer() +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + f.tiles.blocks[8] = {} + local arrive = f.arrive + function f:arrive(id) + arrive(self, id) + self.ow.map.renderer = { rebuild = function() end } + end + function f.ow:replaceBlock(x, y, block) + self.map:setBlock(x, y, block) + self.map.renderer:rebuild() + end + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2 }, { "replace_block", 0, 0, 7 }, { "set_flag", "mod:after" } }), + "future destination block preflight" + ) + f:drain() + T.eq(f.exports.result().success, true, "block edit executes on arrived map") + T.eq(f.ow.map:blockAt(0, 0), 7, "native block patch is on destination") + f.restoreRenderer() +end +-- Exercise the actual native transition; only destination materialization is a fixture. +local OW = require("src.world.OverworldController") +local Transition = require("src.render.Transition") +local StateStack = require("src.core.StateStack") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.startWarpTo, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.startWarpTo, i, game) + return function() + debug.setupvalue(OW.startWarpTo, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, cancel in ipairs({ false, true }) do + local f = fixture(v) + setmetatable(f.game.stack, { __index = StateStack }) + f.ow.startWarpTo = OW.startWarpTo + local restore = bind(f.game) + function f.ow:frlgBehaviours() + return false + end + function f.ow:noteGen2Spawn(from) + self.notedFrom = from + end + function f.ow:setMap(id, x, y, facing, opts) + f.destination = { id, x, y, facing } + f:arrive() + end + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2, "right" }, { "set_flag", "mod:after" } }), + "actual transition admitted" + ) + local state = f.game.stack:top() + T.eq(getmetatable(state), Transition, "native warp transition retained") + if cancel then + f.loader:_rollback("script_travel") + T.eq(f.game.stack:top(), state, "unload retains engine-owned warp transition") + end + for _ = 1, state.frames + state.framesIn + 3 do + if f.game.stack:top() == state then + state:update(1 / 60) + end + f:tick() + end + T.eq(f.ow.map.id, f.dest, "actual transition arrives") + T.eq(f.ow.transitioning, false, "native warp releases its lock even after unload") + T.eq(f.exports.result().success, not cancel, "only live script continues") + T.eq(f.exports.get("mod:after"), not cancel, "no late effects after unload") + restore() + f.restoreRenderer() + end +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script travel") diff --git a/tests/engine/portable_world_scripts.lua b/tests/engine/portable_world_scripts.lua index ccef037d9..ffe1fd55d 100644 --- a/tests/engine/portable_world_scripts.lua +++ b/tests/engine/portable_world_scripts.lua @@ -824,7 +824,8 @@ for _, version in ipairs(GameVersion.ORDER) do }) do T.eq(api:supports("scripts", verb), true, "bounded verb capability") end - T.eq(api:supports("scripts", "warp"), false, "unsupported verb declined") + T.eq(api:supports("scripts", "engine_private_command"), false, "unsupported verb declined") + T.eq(api:supports("scripts", "warp"), GameVersion.generation(version) <= 3, "travel capability matches generation") T.eq(loader.apis.legacy:supports("world", "cancelScript"), false, "legacy has no portable cancellation") T.eq(loader.apis.legacy:supports("scripts", "wait"), false, "native format does not claim portable verbs") end diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index de26d3014..9e710fcfb 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 7f46d4d0a6164ab6101816ebdf09ee8dc7253640 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:37:39 +0200 Subject: [PATCH 25/71] Add portable wild and static battle scripts --- docs/api3-portability.md | 2 +- docs/portable-world-scripts.md | 30 +++ src/mods/Api3WildBattle.lua | 10 +- src/mods/PortableScripts.lua | 121 ++++++++++- src/mods/ScriptBattles.lua | 83 ++++++++ src/mods/ScriptProgram.lua | 15 +- .../mods/script_battles/main.lua | 15 ++ .../mods/script_battles/manifest.json | 1 + tests/api3_contract/script_battles.lua | 64 ++++++ tests/api3_contract/wild_battle.lua | 4 +- tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_battles.lua | 192 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 13 files changed, 533 insertions(+), 11 deletions(-) create mode 100644 src/mods/ScriptBattles.lua create mode 100644 tests/api3_contract/mods/script_battles/main.lua create mode 100644 tests/api3_contract/mods/script_battles/manifest.json create mode 100644 tests/api3_contract/script_battles.lua create mode 100644 tests/engine/portable_script_battles.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index b849bf91c..bcfee128f 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -135,7 +135,7 @@ loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. Eight object/block cases and eight NPC cases extend this to 96 comparisons -and 240 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +and 256 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index dc0820dc4..d38bcbccb 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -353,3 +353,33 @@ The transition belongs to the engine: unloading or timing out a portable script leaves the already admitted native warp free to finish and release its own locks. It suppresses the script continuation, rather than undoing committed native travel or removing the engine's transition screen. + +## Wild and static battles + +Gen1–3 accept `start_battle("wild", speciesId, level)`, +`static_battle(speciesId, level, beatFlag?)` and +`check_battle_result(result, ...)` (1–4 of `win`, `lose`, `run`, `caught`). +Species IDs are canonical strings. Levels are whole numbers 1–100. +All species references and optional beat flags are checked before any +prefix runs. Healthy-party admission is checked at dispatch, so an earlier +`heal_party` can repair the party before the encounter. + +The private script admission guard reuses the public wild-battle adapter, +native constructors, entry transition and `afterBattle`. Ordinary public +calls retain idle-world admission. A static encounter sets its optional +beat flag and hides the talking NPC on win, catch or flee; loss skips both. +Result checks set the condition used by `jump_if_true`/`jump_if_false`. +The foreground continuation waits behind native post-battle and foreign UI. + +Cancelling/unloading suppresses the script continuation while preserving +native battle screens and native cleanup for the same live game/save. +Changed sessions suppress that old-session cleanup. Native constructor, +admission and cleanup errors fail the script; late callbacks are one-shot. +No rollback of already committed battle effects is implied. + +Private beat flags use this host's same namespaced flag service on every +generation. The pinned Gen1 reference delegates Gen1 static encounters to +its native command, which does not route private beat flags through that +namespace. The shared Gen1 static cases therefore exercise a real native +beat flag; host-specific private-flag behavior has separate coverage. +Trainer encounters remain a separate follow-up to this wild/static slice. diff --git a/src/mods/Api3WildBattle.lua b/src/mods/Api3WildBattle.lua index 0cb0ba143..aac918366 100644 --- a/src/mods/Api3WildBattle.lua +++ b/src/mods/Api3WildBattle.lua @@ -47,7 +47,7 @@ local function observe(loader) return unpack(result, 2, result.n) end, math.huge, "engine:api3_wild_battle") end -function B.new(loader, owner, current, native, licensed, req) +function B.new(loader, owner, current, native, licensed, req, ownedReady) local ids = Ids.new(loader) observe(loader) return function(species, level, callback) @@ -62,7 +62,7 @@ function B.new(loader, owner, current, native, licensed, req) if not ow then return nil, "no overworld" end - local ok, reason = Edits.ready(game, ow) + local ok, reason = (ownedReady or Edits.ready)(game, ow) if not ok then return nil, reason end @@ -138,6 +138,9 @@ function B.new(loader, owner, current, native, licensed, req) local called, err = pcall(ow.afterBattle, ow, (...), battle) if not called then Runtime.reportError(owner, "world battle cleanup failed: " .. text(err)) + if ownedReady and valid and fn then + pcall(fn, nil, "native battle cleanup failed: " .. text(err)) + end return end if valid and licensed() and live() and fn then @@ -156,6 +159,9 @@ function B.new(loader, owner, current, native, licensed, req) if not battle or battle.dead then return nil, "battle failed" end + if not token.valid() or ownedReady and not ownedReady(game, ow) then + return nil, "battle session changed" + end battle.onFinish = finish local value, detail = ow:pushBattle(battle) if value == false or value == nil and detail ~= nil then diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index fad11a146..353586284 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -5,6 +5,7 @@ local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") local Movement = require("src.mods.ScriptMovement") local WorldEdits = require("src.mods.ScriptWorldEdits") @@ -191,6 +192,38 @@ local function facade(loader, modId, native) -- a new observer into a live hook chain could replay its current wrapper. observe(loader) local world, active = {}, nil + local nativeGame, nativeWorld + local function battleWorld(game) + if nativeGame ~= game then + nativeWorld = require("src.world.WorldAPI").new(game, modId) + nativeGame = game + end + return nativeWorld + end + local startWild = require("src.mods.Api3WildBattle").new( + loader, + modId, + function() + return current(loader) + end, + battleWorld, + licensed, + require, + function(game, ow) + if + active + and runs[game] == active + and not active.done + and active.world == ow + and ow.runner == active.runner + and game.stack:top() == ow + and active.valid() + then + return true + end + return nil, "portable script does not own this world" + end + ) local function queue(rows, options) if not licensed() then return nil, "script facade is retired" @@ -246,6 +279,10 @@ local function facade(loader, modId, native) if not gift then return nil, giftReason end + local battleOK, battleReason = Battles.prepare(loader, game, ow, program, flag) + if not battleOK then + return nil, battleReason + end local travel, travelReason = Travel.prepare(game, ow, modId, program) if not travel then return nil, travelReason @@ -424,7 +461,7 @@ local function facade(loader, modId, native) return state end discover = function(before, step) - if token.done or token.step ~= step or token.travel then + if token.done or token.step ~= step or token.travel or token.battling then return end for _, state in ipairs(game.stack.states) do @@ -553,6 +590,84 @@ local function facade(loader, modId, native) game.stack:push(menu) runner:yield() end + local function battle(ctx, row) + local verb = row[1] + if verb == "check_battle_result" then + ctx.lastCheck = false + for i = 2, #row do + if row[i] == ctx.lastBattleResult then + ctx.lastCheck = true + end + end + return + end + local species, level = row[2], row[3] + if verb == "start_battle" then + species, level = row[3], row[4] + end + local step, complete, result = token.step, false, nil + token.battling = true + local ok, why = startWild(species, level, function(outcome, failure) + if complete or token.step ~= step or not check() then + return + end + if not Battles.outcomes[outcome] then + token.finish(false, failure or "invalid native battle result") + return + end + complete, result = true, outcome + end) + if not ok then + token.battling = nil + error(why, 0) + end + if not check() then + return + end + if not complete then + runner.waitingCheck = function() + return complete + end + runner:yield() + end + token.battling = nil + if not check() then + return + end + ctx.lastBattleResult, ctx.lastCheck = result, result == "win" + if verb == "static_battle" and result ~= "lose" then + if row[4] ~= nil then + local value, reason = flag(row[4], true, true) + if not value then + error(reason, 0) + end + end + if not check() then + return + end + local npc = movement.npc + local def = npc and npc.def + local ref = def and (def.name or def.localId or def.index or def.id) + if ref then + local value, reason = require("src.mods.ScriptWorldEdits").prepare( + game, + ow, + modId, + { { "hide_object", ow.map.id, ref } } + ) + if not value then + error(reason, 0) + end + local accepted, detail = value({ "hide_object", ow.map.id, ref }, mutate) + if not check() then + return + end + if not accepted then + error(detail, 0) + end + end + end + end local function warp(ctx, row) local plan, reason = travel(row) if not plan then @@ -842,7 +957,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if Travel.ARITY[verb] then + if Battles.ARITY[verb] then + battle(ctx, row) + elseif Travel.ARITY[verb] then warp(ctx, row) elseif Movement.ARITY[verb] then move(ctx, row) diff --git a/src/mods/ScriptBattles.lua b/src/mods/ScriptBattles.lua new file mode 100644 index 000000000..e34efec5d --- /dev/null +++ b/src/mods/ScriptBattles.lua @@ -0,0 +1,83 @@ +-- Portable wild/static battle requests; native battle cleanup owns its lifetime. +local Ids = require("src.mods.UnifiedIds") +local Version = require("src.core.GameVersion") +local B = { ARITY = { start_battle = 4, static_battle = 4, check_battle_result = 5 } } +local outcomes = { win = true, lose = true, run = true, caught = true } +function B.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable battles unavailable for this generation" + end + if verb == "check_battle_result" then + if #row < 2 or #row > 5 then + return nil, "invalid battle result list" + end + local out = { verb } + for i = 2, #row do + if not outcomes[row[i]] then + return nil, "invalid battle result" + end + out[i] = row[i] + end + return out + end + local species, level = row[2], row[3] + if verb == "start_battle" then + if #row ~= 4 or row[2] ~= "wild" then + return nil, "portable start_battle currently requires wild" + end + species, level = row[3], row[4] + elseif #row < 3 or #row > 4 then + return nil, "invalid command arity" + end + if type(species) ~= "string" or #species == 0 or #species > 256 or species:find("%c") then + return nil, "invalid species id" + end + if type(level) ~= "number" or level ~= level or level < 1 or level > 100 or level % 1 ~= 0 then + return nil, "invalid battle level" + end + return { row[1], row[2], row[3], row[4] } +end +function B.prepare(loader, game, world, rows, flag) + local ids = Ids.new(loader) + local talking = world.talkNpc or world.runner and world.runner.ctx and world.runner.ctx.npc + for i, row in ipairs(rows) do + local verb = row[1] + if verb == "warp" then + talking = nil + end + if verb == "start_battle" or verb == "static_battle" then + local species = verb == "start_battle" and row[3] or row[2] + local group, why = ids:resolve("pokemon", species) + if not group or not (game.data.pokemon and game.data.pokemon[group.key]) then + return nil, "row " .. i .. ": " .. (why or "unknown species") + end + if type(world.pushBattle) ~= "function" or type(world.afterBattle) ~= "function" then + return nil, "battle runtime unavailable" + end + if verb == "static_battle" and talking then + local def = talking.def + local ref = def and (def.name or def.localId or def.index or def.id) + if ref then + local edit, reason = require("src.mods.ScriptWorldEdits").prepare( + game, + world, + "", + { { "hide_object", world.map.id, ref } } + ) + if not edit then + return nil, "row " .. i .. ": " .. reason + end + end + end + if verb == "static_battle" and row[4] ~= nil then + local value, reason = flag(row[4]) + if value == nil then + return nil, "row " .. i .. ": " .. reason + end + end + end + end + return true +end +B.outcomes = outcomes +return B diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 1ccf08cd2..7b859ce02 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -2,6 +2,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") local Movement = require("src.mods.ScriptMovement") local WorldEdits = require("src.mods.ScriptWorldEdits") @@ -36,13 +37,16 @@ end for verb, arity in pairs(Travel.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(Battles.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) if type(verb) ~= "string" then return false end if - (WorldEdits.ARITY[verb] or Movement.ARITY[verb] or Travel.ARITY[verb]) + (WorldEdits.ARITY[verb] or Movement.ARITY[verb] or Travel.ARITY[verb] or Battles.ARITY[verb]) and require("src.core.GameVersion").generation() > 3 then return false @@ -128,12 +132,19 @@ function Program.compile(raw, context) and not WorldEdits.ARITY[verb] and not Movement.ARITY[verb] and not Travel.ARITY[verb] + and not Battles.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if Travel.ARITY[verb] then + if Battles.ARITY[verb] then + local value, reason = Battles.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Travel.ARITY[verb] then local value, reason = Travel.syntax(verb, source, context) if not value then return fail(i, reason) diff --git a/tests/api3_contract/mods/script_battles/main.lua b/tests/api3_contract/mods/script_battles/main.lua new file mode 100644 index 000000000..be627b337 --- /dev/null +++ b/tests/api3_contract/mods/script_battles/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_battles/manifest.json b/tests/api3_contract/mods/script_battles/manifest.json new file mode 100644 index 000000000..33210dc51 --- /dev/null +++ b/tests/api3_contract/mods/script_battles/manifest.json @@ -0,0 +1 @@ +{"id":"script_battles","name":"Portable battles","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_battles.lua b/tests/api3_contract/script_battles.lua new file mode 100644 index 000000000..d7198dd11 --- /dev/null +++ b/tests/api3_contract/script_battles.lua @@ -0,0 +1,64 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_battles%.lua$") + local f = dofile(codeRoot .. "/wild_battle.lua").fixture(root, backend, version, "script_battles") + f.loader.game = f.game + f.ow.runner.update = function() end + if f.field then + function f.field.lock(token) + f.field._locks[token] = true + end + function f.field.unlock(token) + f.field._locks[token] = nil + end + end + function f:tick() + self.loader.hooks:call(backend == "gen1" and "core.update" or "input.step", function() + if backend == "gen2" then + self.ow.runner:update() + end + end, self.game, 1 / 60) + end + function f:drain() + for _ = 1, 50 do + if self.exports.result().done > 0 then + return + end + self:tick() + end + error("battle script did not finish") + end + return f +end +function M.run(root, backend, version) + local report = {} + for _, outcome in ipairs({ "win", "lose", "run", "caught" }) do + local f = M.fixture(root, backend, version) + local beat = f.gen == 1 and "EVENT_BEAT_MEWTWO" or "mod:beat" + assert(f.exports.queue({ + { "static_battle", "MEW", 5, beat }, + { "check_battle_result", outcome }, + { "jump_if_false", "end" }, + { "set_flag", "mod:result" }, + })) + assert(f.exports.result().done == 0 and f.pushed == 1) + f:complete(outcome) + f:drain() + assert(f.exports.result().success and f.exports.get("mod:result")) + assert( + f.exports.get(beat) == (outcome ~= "lose") and f.after == 1, + outcome .. " beat=" .. tostring(f.exports.get(beat)) .. " cleanup=" .. f.after + ) + report[outcome] = { beat = f.exports.get(beat), after = f.after, result = true } + f.restore() + end + local f = M.fixture(root, backend, version) + local value, why = + f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "static_battle", "UNKNOWN", 5 } }) + assert(value == nil and type(why) == "string" and f.constructed == 0) + report.preflight = true + f.restore() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/wild_battle.lua b/tests/api3_contract/wild_battle.lua index bbfa86a1f..704addeb1 100644 --- a/tests/api3_contract/wild_battle.lua +++ b/tests/api3_contract/wild_battle.lua @@ -1,8 +1,8 @@ -- Production public facades/loaders; native battle entry/timing seams only. local M = {} -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/wild_battle%.lua$") - local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "wild_battle") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "wild_battle") love = love or require("tests.love_stub") f.game.save.party = { { species = "MEW", hp = 20, level = 10, stats = { hp = 20 } } } f.game.save.pokedex = { seen = {}, caught = {} } diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index d69c8e8dc..0a2522100 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -22,11 +22,14 @@ assert( or mode == "script_world" or mode == "script_motion" or mode == "script_travel" + or mode == "script_battles" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_travel" then +if mode == "script_battles" then + dofile(codeRoot .. "/script_battles.lua").run(root, backend, version) +elseif mode == "script_travel" then dofile(codeRoot .. "/script_travel.lua").run(root, backend, version) elseif mode == "script_motion" then dofile(codeRoot .. "/script_motion.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_battles.lua b/tests/engine/portable_script_battles.lua new file mode 100644 index 000000000..429afb3cf --- /dev/null +++ b/tests/engine/portable_script_battles.lua @@ -0,0 +1,192 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_battles") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Battle = require("src.battle.BattleState") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared script battle: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "set_flag", "mod:prefix" }, { "start_battle", "wild", "UNKNOWN", 5 } }, + { { "start_battle", "wild", "MEW", 0 } }, + { { "start_battle", "wild", "MEW", 101 } }, + { { "start_battle", "trainer", "ANY", 1 } }, + { { "static_battle", "MEW", 5, "mod.other.bad" } }, + { { "static_battle", "MEW", 5 }, { "check_battle_result", "invalid" } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid battle list preflight") + T.eq(f.exports.get("mod:prefix"), false, "invalid suffix has no flag prefix") + T.eq(f.constructed, 0, "preflight constructs no native battle") + f.restore() +end +for _, outcome in ipairs({ "win", "lose", "run", "caught" }) do + local f = fixture() + T.check( + f.exports.queue({ + { "static_battle", "MEW", 5, "mod:beat" }, + { "check_battle_result", outcome }, + { "jump_if_false", "end" }, + { "set_flag", "mod:after" }, + }), + "private static beat flag admitted" + ) + f:complete(outcome) + f:drain() + T.eq(f.exports.get("mod:beat"), outcome ~= "lose", "private static flag is generation independent") + T.eq(f.exports.get("mod:after"), true, "battle result branch executes") + f.restore() +end +for _, change in ipairs({ "cancel", "rollback", "save", "data", "player" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "static_battle", "MEW", 5, "mod:beat" }, { "set_flag", "mod:after" } }), + "battle admitted before retirement" + ) + local finish = f.finish + if change == "cancel" then + require("src.mods.PortableScripts").cancelAll(f.loader, "script_battles") + elseif change == "rollback" then + f.loader:_rollback("script_battles") + elseif change == "player" then + f.ow.player = {} + else + f.game[change] = {} + end + f:tick() + T.eq(f.game.stack:top(), f.battle, "cancelling script preserves native battle screen") + T.eq(f.exports.result().done, 1, "retirement completes once") + T.eq(f.exports.result().success, false, "retired battle cannot finish script") + f:complete("caught") + finish("win") + finish("run") + T.eq( + f.after, + (change == "cancel" or change == "rollback") and 1 or 0, + "cleanup only for retained native session" + ) + T.eq(f.exports.result().done, 1, "native finish cannot replay retired completion") + T.eq(f.ow.runner, original, "idle runner restored") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "set_flag", "mod:after" } }), + "battle before foreign overlay" + ) + local callback = f.finish + f:complete("win") + local overlay = {} + f.game.stack.states[2] = overlay + callback("lose") + callback("run") + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.get("mod:after"), false, "battle callback cannot resume under foreign UI") + T.eq(f.after, 1, "native cleanup is one-shot") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.get("mod:after"), true, "foreground battle continuation runs") + f.restore() +end +for _, failure in ipairs({ "constructError", "pushFailure", "cleanupError", "unhealthy", "constructUnload" }) do + local f = fixture() + if failure == "unhealthy" then + f.game.save.party[1].hp = 0 + elseif failure == "constructUnload" then + local native = package.loaded["src.battle.BattleState"].newWild + package.loaded["src.battle.BattleState"].newWild = function(...) + local result = native(...) + f.loader:_rollback("script_battles") + return result + end + else + f[failure] = true + end + T.check( + f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "set_flag", "mod:after" } }), + "admitted program reports native failure" + ) + if failure == "cleanupError" then + f:complete("win") + end + T.eq(f.exports.result().success, false, "native failure finishes script") + T.eq(f.exports.get("mod:after"), false, "native failure suppresses suffix") + if failure == "constructUnload" then + T.eq(f.pushed, 0, "owner retired during construction cannot push battle") + end + f.restore() +end +do + local f = fixture() + f.synchronous = true + T.check( + f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "set_flag", "mod:after" } }), + "synchronous battle admitted" + ) + T.eq(f.exports.result().success, true, "synchronous battle completion handled") + T.eq(f.after, 1, "synchronous cleanup once") + f.restore() +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, outcome in ipairs({ "win", "lose", "run", "caught" }) do + local f = fixture(v) + local def = { name = "NPC", index = 1, localId = 1, x = 1, y = 1, eventFlag = "FLAG_A", flag = 7 } + f.ow.map.def.objects = { def } + f.ow.talkNpc = { def = def, cellX = 1, cellY = 1 } + function f.ow:syncObjectVisibility() + f.hidden = true + end + function f.ow:setMap() + f.hidden = f.game.save.objectToggles[f.home].NPC == false + end + T.check( + f.exports.queue({ { "static_battle", "MEW", 5, "mod:beat" }, { "set_flag", "mod:after" } }), + "static NPC battle admitted" + ) + T.eq(f.hidden, nil, "NPC stays visible during battle") + f:complete(outcome) + f:drain() + T.eq(f.exports.result().success, true, "static NPC continuation succeeds") + T.eq(f.hidden == true, outcome ~= "lose", "static NPC hidden on every non-loss outcome") + T.eq(f.exports.get("mod:after"), true, "static object update resumes later rows") + f.restore() + end +end +-- Actual native constructors and Pokémon creation, retaining the entry timing seam. +for _, v in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(v) + f.restore() + local data = T.fixtures.fresh() + data.pokemon.MEW = require("src.mods.Merge").deepCopy(data.pokemon.FIXMON_A) + data.pokemon.MEW.id, data.pokemon.MEW.name = "MEW", "MEW" + data.constants.gen = Version.generation(v) + f.game.data = data + f.game.save = require("src.core.SaveData").newGame() + f.game.session = f.game.save + f.game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 15) } + require("src.render.Font").load(data) + T.check( + f.exports.queue({ { "static_battle", "MEW", 5, "mod:beat" }, { "set_flag", "mod:after" } }), + v .. " native constructor admitted" + ) + T.eq(f.battle.kind, "wild", "native battle kind") + T.eq(f.battle.enemy.mon.species, "MEW", "native enemy species") + f:complete("caught") + f:drain() + T.eq(f.after, 1, "native cleanup retained") + T.eq(f.exports.get("mod:beat"), true, "actual native battle resumes static suffix") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script battles") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 9e710fcfb..89d49cc64 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 089a4c9c1dbdbbbd65fd02bee19ad3af3f356d00 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:49:05 +0200 Subject: [PATCH 26/71] Add native portable trainer battle scripts --- docs/api3-portability.md | 2 +- docs/portable-world-scripts.md | 27 ++- src/mods/Api3WildBattle.lua | 60 ++++-- src/mods/PortableScripts.lua | 49 +++-- src/mods/ScriptBattles.lua | 117 ++++++++++- .../mods/script_trainers/main.lua | 15 ++ .../mods/script_trainers/manifest.json | 1 + tests/api3_contract/script_battles.lua | 4 +- tests/api3_contract/script_trainers.lua | 82 ++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_trainers.lua | 198 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 12 files changed, 514 insertions(+), 48 deletions(-) create mode 100644 tests/api3_contract/mods/script_trainers/main.lua create mode 100644 tests/api3_contract/mods/script_trainers/manifest.json create mode 100644 tests/api3_contract/script_trainers.lua create mode 100644 tests/engine/portable_script_trainers.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index bcfee128f..5025399e6 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -135,7 +135,7 @@ loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. Eight object/block cases and eight NPC cases extend this to 96 comparisons -and 256 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +and 272 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index d38bcbccb..a00d1b543 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -382,4 +382,29 @@ generation. The pinned Gen1 reference delegates Gen1 static encounters to its native command, which does not route private beat flags through that namespace. The shared Gen1 static cases therefore exercise a real native beat flag; host-specific private-flag behavior has separate coverage. -Trainer encounters remain a separate follow-up to this wild/static slice. +Trainer encounters are described below. + + +## Trainer encounters + +`start_battle("trainer", trainerRef, partyIndex?)` uses this version's native +trainer data and constructor. Gen1 normally selects a class key and numbered +party; Gen2 accepts the native class number (resolved to the host's class +record); Gen3 accepts a native trainer number through `trainerOrder`, with +one party per trainer. Existing host keys are also accepted. These selectors +are game-specific and are not universal trainer IDs. Use an explicit party +index for shared Gen2 scripts: the pinned reference requires that argument. + +Preflight validates every referenced roster before a prefix runs and repeats +that check at dispatch. Unknown species, invalid levels, empty/oversized +parties, ambiguous class aliases and unavailable Gen3 flag metadata are +refused. Native class aliases select the canonical class deterministically. +Gen3 named reference constants are not guessed from display names: use the +native trainer number for engine portability. + +Trainer battles share the same owned continuation, cancellation and native +cleanup lifetime as wild encounters. Gen3 wins use the host's native trainer +victory flag and Match Call registration helpers; loss does not mark a win. +Cleanup remains valid after the originating mod retires, provided the same +native session still exists. Native battle mechanics and game-specific story +rewards remain the responsibility of this host's battle/field code. diff --git a/src/mods/Api3WildBattle.lua b/src/mods/Api3WildBattle.lua index aac918366..8f5ca5ece 100644 --- a/src/mods/Api3WildBattle.lua +++ b/src/mods/Api3WildBattle.lua @@ -1,4 +1,5 @@ -- API-3 encounter admission over native construction, transition and afterBattle. +-- Portable scripts may supply a private owned guard and native trainer plan. local Runtime = require("src.mods.Runtime") local Version = require("src.core.GameVersion") local Edits = require("src.mods.Api3WorldEdits") @@ -47,7 +48,7 @@ local function observe(loader) return unpack(result, 2, result.n) end, math.huge, "engine:api3_wild_battle") end -function B.new(loader, owner, current, native, licensed, req, ownedReady) +function B.new(loader, owner, current, native, licensed, req, ownedReady, ownedNative) local ids = Ids.new(loader) observe(loader) return function(species, level, callback) @@ -74,22 +75,33 @@ function B.new(loader, owner, current, native, licensed, req, ownedReady) retire(token) end end - if type(species) ~= "string" or #species == 0 or #species > 256 or species:find("%c") then - return nil, "invalid species id" - end - if type(level) ~= "number" or level ~= level or level < 1 or level > 100 or level % 1 ~= 0 then - return nil, "level must be a whole number 1..100" - end - if callback ~= nil and type(callback) ~= "function" then - return nil, "invalid callback" - end - local group, why = ids:resolve("pokemon", species) - if not group then - return nil, why:find("^ambiguous") and why or "unknown species: " .. species - end - local key = group.key - if not (game.data.pokemon and game.data.pokemon[key]) then - return nil, "unknown species: " .. species + local key, why + if ownedNative then + key, why = ownedNative.resolve(game, species, level) + if not key then + return nil, why + end + if callback ~= nil and type(callback) ~= "function" then + return nil, "invalid callback" + end + else + if type(species) ~= "string" or #species == 0 or #species > 256 or species:find("%c") then + return nil, "invalid species id" + end + if type(level) ~= "number" or level ~= level or level < 1 or level > 100 or level % 1 ~= 0 then + return nil, "level must be a whole number 1..100" + end + if callback ~= nil and type(callback) ~= "function" then + return nil, "invalid callback" + end + local group, why = ids:resolve("pokemon", species) + if not group then + return nil, why:find("^ambiguous") and why or "unknown species: " .. species + end + key = group.key + if not (game.data.pokemon and game.data.pokemon[key]) then + return nil, "unknown species: " .. species + end end if not Party.firstHealthy(game.save.party or {}) then return nil, "no healthy party" @@ -135,7 +147,13 @@ function B.new(loader, owner, current, native, licensed, req, ownedReady) return end -- Native cleanup still runs after the originating mod has been unloaded. - local called, err = pcall(ow.afterBattle, ow, (...), battle) + local outcome = (...) + local called, err = pcall(function() + if ownedNative and ownedNative.cleanup then + ownedNative.cleanup(game, ow, key, outcome, battle) + end + ow:afterBattle(outcome, battle) + end) if not called then Runtime.reportError(owner, "world battle cleanup failed: " .. text(err)) if ownedReady and valid and fn then @@ -155,7 +173,11 @@ function B.new(loader, owner, current, native, licensed, req, ownedReady) end pending[token] = true local called, accepted, err = pcall(Edits.run, game, function() - battle = req("src.battle.BattleState").newWild(game, key, level) + if ownedNative then + battle = ownedNative.construct(game, key) + else + battle = req("src.battle.BattleState").newWild(game, key, level) + end if not battle or battle.dead then return nil, "battle failed" end diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index 353586284..c6c43101d 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -200,29 +200,41 @@ local function facade(loader, modId, native) end return nativeWorld end + local function ownedBattle(game, ow) + if + active + and runs[game] == active + and not active.done + and active.world == ow + and ow.runner == active.runner + and game.stack:top() == ow + and active.valid() + then + return true + end + return nil, "portable script does not own this world" + end + local function provider() + return current(loader) + end local startWild = require("src.mods.Api3WildBattle").new( loader, modId, - function() - return current(loader) - end, + provider, battleWorld, licensed, require, - function(game, ow) - if - active - and runs[game] == active - and not active.done - and active.world == ow - and ow.runner == active.runner - and game.stack:top() == ow - and active.valid() - then - return true - end - return nil, "portable script does not own this world" - end + ownedBattle + ) + local startTrainer = require("src.mods.Api3WildBattle").new( + loader, + modId, + provider, + battleWorld, + licensed, + require, + ownedBattle, + Battles.nativeTrainer ) local function queue(rows, options) if not licensed() then @@ -607,7 +619,8 @@ local function facade(loader, modId, native) end local step, complete, result = token.step, false, nil token.battling = true - local ok, why = startWild(species, level, function(outcome, failure) + local start = verb == "start_battle" and row[2] == "trainer" and startTrainer or startWild + local ok, why = start(species, level, function(outcome, failure) if complete or token.step ~= step or not check() then return end diff --git a/src/mods/ScriptBattles.lua b/src/mods/ScriptBattles.lua index e34efec5d..2dc34dcfa 100644 --- a/src/mods/ScriptBattles.lua +++ b/src/mods/ScriptBattles.lua @@ -3,6 +3,91 @@ local Ids = require("src.mods.UnifiedIds") local Version = require("src.core.GameVersion") local B = { ARITY = { start_battle = 4, static_battle = 4, check_battle_result = 5 } } local outcomes = { win = true, lose = true, run = true, caught = true } +local function integer(n, lo, hi) + return type(n) == "number" and n == n and n >= lo and n <= hi and n % 1 == 0 +end +function B.trainer(game, reference, member) + local gen = Version.generation() + local rows = game.data.trainers or {} + local key, found + if type(reference) == "number" then + if gen == 3 then + key = game.data.constants + and game.data.constants.trainerOrder + and game.data.constants.trainerOrder[reference] + else + for id, row in pairs(rows) do + if type(row) == "table" and row.index == reference then + local candidateKey = row.id and rows[row.id] and row.id or id + local candidate = rows[candidateKey] + if candidate.index ~= reference then + return nil, "inconsistent trainer alias" + end + if found and found ~= candidate then + return nil, "ambiguous trainer class" + end + key, found = candidateKey, candidate + end + end + end + else + key = reference + end + local row = key and rows[key] + local party = member or 1 + if gen == 3 and party ~= 1 then + return nil, "Gen3 trainer has one party" + end + local roster = row and row.parties and row.parties[party] + if type(roster) ~= "table" or #roster == 0 or #roster > 6 then + return nil, "trainer content unavailable: " .. tostring(reference) + end + for _, mon in ipairs(roster) do + if + type(mon) ~= "table" + or not integer(mon.level, 1, 100) + or not (game.data.pokemon and game.data.pokemon[mon.species]) + then + return nil, "invalid native trainer party" + end + end + local nativeIndex = row and row.index + if gen == 3 then + local order = game.data.constants and game.data.constants.trainerOrder or {} + nativeIndex = nil + for index, id in pairs(order) do + if id == key then + if nativeIndex and nativeIndex ~= index then + return nil, "ambiguous native trainer id" + end + nativeIndex = index + end + end + local base = game.data.constants and game.data.constants.gen3TrainerFlagBase + if + not integer(nativeIndex, 1, 65535) + or not integer(base, 0, 65535 - nativeIndex) + or game.save.flags ~= nil and type(game.save.flags) ~= "table" + then + return nil, "native trainer flags unavailable" + end + end + return { key = key, member = party, index = nativeIndex } +end +B.nativeTrainer = { + resolve = B.trainer, + construct = function(game, plan) + return require("src.battle.BattleState").newTrainer(game, plan.key, plan.member) + end, + cleanup = function(game, world, plan, outcome) + if Version.generation() == 3 and outcome == "win" then + local ctx = { game = game, save = game.save, overworld = world } + local commands = require("src.script.Gen3Commands") + commands.markTrainerBeaten(ctx, plan.index) + commands.registerTrainerInMatchCall(ctx, plan.index) + end + end, +} function B.syntax(verb, row, context) if (context.generation or Version.generation()) > 3 then return nil, "portable battles unavailable for this generation" @@ -22,8 +107,23 @@ function B.syntax(verb, row, context) end local species, level = row[2], row[3] if verb == "start_battle" then + if row[2] == "trainer" then + if + #row < 3 + or #row > 4 + or not (type(row[3]) == "string" and #row[3] > 0 and #row[3] <= 256 and not row[3]:find("%c") or integer( + row[3], + 0, + 65535 + )) + or row[4] ~= nil and not integer(row[4], 1, 255) + then + return nil, "invalid script trainer" + end + return { row[1], row[2], row[3], row[4] } + end if #row ~= 4 or row[2] ~= "wild" then - return nil, "portable start_battle currently requires wild" + return nil, "invalid script battle" end species, level = row[3], row[4] elseif #row < 3 or #row > 4 then @@ -46,10 +146,17 @@ function B.prepare(loader, game, world, rows, flag) talking = nil end if verb == "start_battle" or verb == "static_battle" then - local species = verb == "start_battle" and row[3] or row[2] - local group, why = ids:resolve("pokemon", species) - if not group or not (game.data.pokemon and game.data.pokemon[group.key]) then - return nil, "row " .. i .. ": " .. (why or "unknown species") + if verb == "start_battle" and row[2] == "trainer" then + local plan, why = B.trainer(game, row[3], row[4]) + if not plan then + return nil, "row " .. i .. ": " .. why + end + else + local species = verb == "start_battle" and row[3] or row[2] + local group, why = ids:resolve("pokemon", species) + if not group or not (game.data.pokemon and game.data.pokemon[group.key]) then + return nil, "row " .. i .. ": " .. (why or "unknown species") + end end if type(world.pushBattle) ~= "function" or type(world.afterBattle) ~= "function" then return nil, "battle runtime unavailable" diff --git a/tests/api3_contract/mods/script_trainers/main.lua b/tests/api3_contract/mods/script_trainers/main.lua new file mode 100644 index 000000000..be627b337 --- /dev/null +++ b/tests/api3_contract/mods/script_trainers/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_trainers/manifest.json b/tests/api3_contract/mods/script_trainers/manifest.json new file mode 100644 index 000000000..66dd1a92c --- /dev/null +++ b/tests/api3_contract/mods/script_trainers/manifest.json @@ -0,0 +1 @@ +{"id":"script_trainers","name":"Portable trainer battles","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_battles.lua b/tests/api3_contract/script_battles.lua index d7198dd11..f29912824 100644 --- a/tests/api3_contract/script_battles.lua +++ b/tests/api3_contract/script_battles.lua @@ -1,7 +1,7 @@ local M = {} -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_battles%.lua$") - local f = dofile(codeRoot .. "/wild_battle.lua").fixture(root, backend, version, "script_battles") + local f = dofile(codeRoot .. "/wild_battle.lua").fixture(root, backend, version, modId or "script_battles") f.loader.game = f.game f.ow.runner.update = function() end if f.field then diff --git a/tests/api3_contract/script_trainers.lua b/tests/api3_contract/script_trainers.lua new file mode 100644 index 000000000..81d2a4d8b --- /dev/null +++ b/tests/api3_contract/script_trainers.lua @@ -0,0 +1,82 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_trainers%.lua$") + local f = dofile(codeRoot .. "/script_battles.lua").fixture(root, backend, version, "script_trainers") + f.game.data.trainers = + { PROBE = { id = "PROBE", index = 7, name = "PROBE", parties = { { { species = "MEW", level = 5 } } } } } + f.game.data.constants = f.game.data.constants or {} + f.game.data.constants.trainerOrder = { [7] = "PROBE" } + f.game.data.constants.gen3TrainerFlagBase = 0x500 + local battle = package.loaded["src.battle.BattleState"] + battle.newTrainer = function(game, class, member) + f.constructed = f.constructed + 1 + if f.constructError then + error("trainer constructor probe", 0) + end + return { kind = "trainer", class = class, member = member } + end + f.trainerRef = f.gen == 1 and "PROBE" or 7 + if backend == "gen1" and f.gen == 2 then + f.game.data.trainers = { + classes = { + PROBE = { + id = "PROBE", + index = 7, + name = "PROBE", + trainers = { { id = "PROBE_1", name = "PROBE", party = { { species = "MEW", level = 5 } } } }, + }, + }, + } + function f.ow:trainerParty(class, member) + return require("src.world.gen2.TrainerHouse").lookup(f.game.data.trainers, f.game.save, class, member) + end + function f.ow:startScriptedBattle(trainer, _, done) + f.constructed = f.constructed + 1 + return f:admit({ kind = "trainer", trainer = trainer }, done, true) + end + elseif backend == "gen1" and f.gen == 3 then + package.loaded["src.core.game3.scripting.trainers"] = { + get = function(id) + return id == 7 and { party = { { species = 151, level = 5 } } } or nil + end, + foeFromId = function(id) + return id == 7 and { trainerId = id, party = { { species = 151, level = 5 } } } or nil + end, + } + package.loaded["src.core.game3.battle_bridge"].start = function(_, game, foe, opts) + f.constructed = f.constructed + 1 + local value, why = f:admit({ kind = "trainer", foe = foe }, opts.done, true) + if value == false then + return value, why + end + return true + end + end + return f +end +function M.run(root, backend, version) + local report = {} + for _, outcome in ipairs({ "win", "lose", "run" }) do + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "start_battle", "trainer", f.trainerRef, 1 }, + { "check_battle_result", outcome }, + { "jump_if_false", "end" }, + { "set_flag", "mod:after" }, + })) + assert(f.constructed == 1 and f.pushed == 1 and f.exports.result().done == 0) + f:complete(outcome) + f:drain() + assert(f.after == 1 and f.exports.result().success and f.exports.get("mod:after")) + report[outcome] = { after = f.after, complete = true } + f.restore() + end + local f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "set_flag", "mod:prefix" }, { "start_battle", "trainer", 999, 1 } }) + assert(value == nil and type(why) == "string" and not f.exports.get("mod:prefix") and f.constructed == 0) + report.preflight = true + f.restore() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 0a2522100..947f910c3 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -23,11 +23,14 @@ assert( or mode == "script_motion" or mode == "script_travel" or mode == "script_battles" + or mode == "script_trainers" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_battles" then +if mode == "script_trainers" then + dofile(codeRoot .. "/script_trainers.lua").run(root, backend, version) +elseif mode == "script_battles" then dofile(codeRoot .. "/script_battles.lua").run(root, backend, version) elseif mode == "script_travel" then dofile(codeRoot .. "/script_travel.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_trainers.lua b/tests/engine/portable_script_trainers.lua new file mode 100644 index 000000000..022ecd236 --- /dev/null +++ b/tests/engine/portable_script_trainers.lua @@ -0,0 +1,198 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_trainers") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared trainer contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "set_flag", "mod:prefix" }, { "start_battle", "trainer", 999, 1 } }, + { { "start_battle", "trainer", {}, 1 } }, + { { "start_battle", "trainer", "PROBE", 0 } }, + { { "start_battle", "trainer", "PROBE", 256 } }, + { { "start_battle", "trainer", "PROBE", 2 } }, + { { "start_battle", "trainer", "PROBE", 1, {} } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid trainer preflight") + T.eq(f.constructed, 0, "invalid trainer does not construct") + T.eq(f.exports.get("mod:prefix"), false, "invalid suffix prevents prefix") + f.restore() +end +for _, change in ipairs({ "species", "level", "empty", "oversized", "ambiguous" }) do + local f = fixture("gold") + local row = f.game.data.trainers.PROBE + if change == "species" then + row.parties[1][1].species = "UNKNOWN" + elseif change == "level" then + row.parties[1][1].level = 101 + elseif change == "empty" then + row.parties[1] = {} + elseif change == "oversized" then + for i = 2, 7 do + row.parties[1][i] = row.parties[1][1] + end + else + f.game.data.trainers.OTHER = { id = "OTHER", index = 7, parties = row.parties } + end + T.eq( + f.exports.queue({ { "set_flag", "mod:prefix" }, { "start_battle", "trainer", 7, 1 } }), + nil, + "invalid native trainer data: " .. change + ) + T.eq(f.constructed, 0, "invalid data never constructs") + T.eq(f.exports.get("mod:prefix"), false, "invalid data never runs prefix") + f.restore() +end +do + local f = fixture("gold") + f.game.data.trainers.ALIAS = require("src.mods.Merge").deepCopy(f.game.data.trainers.PROBE) + T.check(f.exports.queue({ { "start_battle", "trainer", 7, 1 } }), "canonical native alias resolves once") + T.eq(f.battle.class, "PROBE", "native class identity selected deterministically") + f:complete("win") + f:drain() + f.restore() +end +for _, change in ipairs({ "missing_order", "ambiguous_order", "missing_base", "invalid_flags", "member" }) do + local f = fixture("firered") + local row = { "start_battle", "trainer", 7, 1 } + if change == "missing_order" then + f.game.data.constants.trainerOrder = {} + elseif change == "ambiguous_order" then + f.game.data.constants.trainerOrder[8] = "PROBE" + elseif change == "missing_base" then + f.game.data.constants.gen3TrainerFlagBase = nil + elseif change == "invalid_flags" then + f.game.save.flags = false + else + row[4] = 2 + end + T.eq(f.exports.queue({ row }), nil, "unrepresentable Gen3 trainer refused: " .. change) + T.eq(f.constructed, 0, "Gen3 refusal has no constructor") + f.restore() +end +for _, change in ipairs({ "rollback", "save", "data", "player", "cancel" }) do + local f = fixture("firered") + T.check( + f.exports.queue({ { "start_battle", "trainer", 7, 1 }, { "set_flag", "mod:after" } }), + "trainer admitted before retirement" + ) + local callback = f.finish + if change == "rollback" then + f.loader:_rollback("script_trainers") + elseif change == "player" then + f.ow.player = {} + elseif change == "cancel" then + require("src.mods.PortableScripts").cancelAll(f.loader, "script_trainers") + else + f.game[change] = {} + end + f:tick() + T.eq(f.game.stack:top(), f.battle, "trainer native screen survives script retirement") + f:complete("win") + callback("lose") + T.eq(f.exports.result().success, false, "retired trainer cannot continue") + T.eq( + f.after, + (change == "rollback" or change == "cancel") and 1 or 0, + "native cleanup only on same session" + ) + T.eq(f.exports.result().done, 1, "late trainer callback completes once") + if change == "rollback" or change == "cancel" then + T.eq( + f.game.save.flags.FLAG_G3_0507, + true, + "native trainer victory flag survives originating mod retirement" + ) + end + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "wait", 1 }, { "start_battle", "trainer", "PROBE", 1 } }), + "deferred trainer preflight" + ) + f.game.data.trainers.PROBE.parties[1][1].species = "UNKNOWN" + f:drain() + T.eq(f.exports.result().success, false, "trainer data revalidated at dispatch") + T.eq(f.constructed, 0, "changed live roster cannot construct") + f.restore() +end +for _, failure in ipairs({ "constructError", "pushFailure", "cleanupError", "constructUnload" }) do + local f = fixture() + if failure == "constructUnload" then + local native = package.loaded["src.battle.BattleState"].newTrainer + package.loaded["src.battle.BattleState"].newTrainer = function(...) + local result = native(...) + f.loader:_rollback("script_trainers") + return result + end + else + f[failure] = true + end + T.check( + f.exports.queue({ { "start_battle", "trainer", "PROBE", 1 }, { "set_flag", "mod:after" } }), + "trainer native failure admitted" + ) + if failure == "cleanupError" then + f:complete("win") + end + T.eq(f.exports.result().success, false, "trainer native failure terminates") + T.eq(f.exports.get("mod:after"), false, "trainer native failure suppresses suffix") + if failure == "constructUnload" then + T.eq(f.pushed, 0, "retired constructor cannot push trainer battle") + end + f.restore() +end +for _, v in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + for _, outcome in ipairs({ "win", "lose" }) do + local f = fixture(v) + f.restore() + local data = T.fixtures.fresh() + data.trainers.PROBE = { + id = "PROBE", + index = 7, + name = "PROBE", + baseMoney = 10, + parties = { { { species = "FIXMON_B", level = 5 } } }, + } + data.constants.gen = Version.generation(v) + data.constants.trainerOrder = { [7] = "PROBE" } + data.constants.gen3TrainerFlagBase = 0x500 + f.game.data = data + f.game.save = require("src.core.SaveData").newGame() + f.game.session = f.game.save + f.game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 15) } + require("src.render.Font").load(data) + T.check( + f.exports.queue({ + { "start_battle", "trainer", f.trainerRef, 1 }, + { "check_battle_result", outcome }, + { "jump_if_false", "end" }, + { "set_flag", "mod:after" }, + }), + v .. " native trainer admitted" + ) + T.eq(f.battle.kind, "trainer", "actual native trainer kind") + T.eq(f.battle.enemy.mon.species, "FIXMON_B", "actual native trainer party built") + T.eq(f.battle.partyIndex, 1, "actual native member selected") + f:complete(outcome) + f:drain() + T.eq(f.after, 1, "actual trainer cleanup retained") + T.eq(f.exports.get("mod:after"), true, "native trainer outcome branch") + if f.gen == 3 then + T.eq(f.game.save.flags.FLAG_G3_0507 == true, outcome == "win", "native trainer flag tracks win only") + end + end +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script trainers") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 89d49cc64..250603752 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 1a09b6c7847a723805fd39e88ea0be17f5dc7795 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:18:55 +0200 Subject: [PATCH 27/71] Add portable native marts with owned transaction callbacks --- docs/api3-portability.md | 2 +- docs/portable-world-scripts.md | 26 ++ src/mods/PortableScripts.lua | 117 ++++++- src/mods/ScriptProgram.lua | 22 +- src/mods/ScriptShopUI.lua | 61 ++++ src/mods/ScriptShops.lua | 121 +++++++ src/mods/ScriptTravel.lua | 8 +- .../api3_contract/mods/script_shops/main.lua | 15 + .../mods/script_shops/manifest.json | 1 + tests/api3_contract/script_gifts.lua | 4 +- tests/api3_contract/script_shops.lua | 83 +++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_shops.lua | 329 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 14 files changed, 784 insertions(+), 12 deletions(-) create mode 100644 src/mods/ScriptShopUI.lua create mode 100644 src/mods/ScriptShops.lua create mode 100644 tests/api3_contract/mods/script_shops/main.lua create mode 100644 tests/api3_contract/mods/script_shops/manifest.json create mode 100644 tests/api3_contract/script_shops.lua create mode 100644 tests/engine/portable_script_shops.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 5025399e6..55dbe5fee 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -135,7 +135,7 @@ loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. Eight object/block cases and eight NPC cases extend this to 96 comparisons -and 272 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +and 288 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index a00d1b543..e20c5d4d2 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -408,3 +408,29 @@ victory flag and Match Call registration helpers; loss does not mark a win. Cleanup remains valid after the originating mod retires, provided the same native session still exists. Native battle mechanics and game-specific story rewards remain the responsibility of this host's battle/field code. + +## Native marts + +`open_mart(textConstant)` opens the native mart selected by the active map's +`data:textEntry(map.label, textConstant).mart` entry. Entries hold 1–99 item +IDs; canonical names and known native item IDs resolve through this boot's +item registry. Every mart row, including a row after a warp, is validated +before any prefix runs. Missing data is refused rather than guessed from +unrelated ROM pointers. Live dispatch repeats content/store validation. + +The screen factory and native buy/sell/quantity/confirmation flows remain +in charge of transactions. The script waits until the root counter closes. +UI callbacks are bound to their exact active screen and phase: closed, +cancelled, superseded or retired quantity/confirmation callbacks cannot +replay a transaction. Foreign overlays block callbacks and continuation. +Nested owned screens are removed on cancellation while foreign UI remains. +A retired constructor cannot leave its late native root on the stack. + +Wallet/inventory shapes, counts and prices must be representable (whole +non-negative amounts; prices at most 999,999, inventory at most 4,096 +entries). Checked sale totals must fit the exact integer range. Replaced +inventories or changed item records/prices cancel an open shop before later +callbacks run. Committed native transactions remain committed after +cancellation. The same guard covers classic lists, FireRed's inline sale +quantity and Emerald's native sell bag. Closed UI records are released +between frames rather than accumulated for a long shopping session. diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index c6c43101d..a3b56aa46 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -5,6 +5,7 @@ local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") local Movement = require("src.mods.ScriptMovement") @@ -145,6 +146,8 @@ local function observe(loader) token.finish(false, ok and why or "script context failed") elseif token.waitingUI and not contains(token.stack, token.waitingUI) then token.finish(false, "script UI was removed") + elseif token.shop then + token.shop.refresh() elseif token.travel then token.travel.tick() end @@ -295,7 +298,7 @@ local function facade(loader, modId, native) if not battleOK then return nil, battleReason end - local travel, travelReason = Travel.prepare(game, ow, modId, program) + local travel, travelReason = Travel.prepare(game, ow, modId, program, loader) if not travel then return nil, travelReason end @@ -349,6 +352,7 @@ local function facade(loader, modId, native) or not token.travel and (ow.player.cellX ~= token.x or ow.player.cellY ~= token.y) and not (token.move and token.move.position()) or not token.travel and not token.mutating and ow.runner ~= runner or token.move and not token.move.live() + or token.shop and not token.shop.valid() or GameVersion.get() ~= token.version or Runtime.hooks ~= loader.hooks or Runtime.events ~= loader.events @@ -462,14 +466,25 @@ local function facade(loader, modId, native) return end local before = token.nativeStep == step and snapshot() + local previous, phase, priorBefore = token.executingUI, token.uiPhase, token.uiBefore + if token.shop and token.shop.step == step then + token.executingUI, token.uiPhase, token.uiBefore = self, self.asking, before + end local ok, err = engine(update, self, ...) if before then discover(before, step) end + if token.shop then + token.shop.refresh() + end + token.executingUI, token.uiPhase, token.uiBefore = previous, phase, priorBefore if not ok then token.finish(false, "native UI failed: " .. message(err)) end end + if token.shop then + token.shop.attach(state) + end return state end discover = function(before, step) @@ -479,7 +494,7 @@ local function facade(loader, modId, native) for _, state in ipairs(game.stack.states) do if not before[state] then owned(state, step) - if token.nativeStep == step then + if token.nativeStep == step and not token.shop then token.waitingUI = state end end @@ -602,6 +617,100 @@ local function facade(loader, modId, native) game.stack:push(menu) runner:yield() end + local function shop(ctx, row) + local stock, validate = Shops.plan(loader, game, ow, row) + if not stock then + error(validate, 0) + end + local step, complete, root = token.step, false, nil + local service = { step = step } + function service.valid() + if not complete and root and not contains(token.stack, root) and token.executingUI ~= root then + return false + end + return validate() + end + local invoke + service.attach = require("src.mods.ScriptShopUI").new({ + alive = function(state) + return not complete + and token.shop == service + and token.step == step + and check() + and ( + game.stack:top() == state + or token.executingUI == state + or token.executingUI + and token.ownedUI[token.executingUI] + and (contains(token.stack, state) or token.uiBefore and token.uiBefore[state]) + ) + end, + phase = function(state) + return token.executingUI == state and token.uiPhase or nil + end, + invoke = function(state, fn, ...) + return invoke(state, fn, ...) + end, + }) + function service.refresh() + if token.done then + return + end + if not token.executingUI then + for i = #token.ui, 1, -1 do + if not contains(token.stack, token.ui[i]) then + token.ownedUI[token.ui[i]] = nil + table.remove(token.ui, i) + end + end + end + for _, state in ipairs(token.ui) do + if contains(token.stack, state) then + service.attach(state) + end + end + end + invoke = function(state, fn, ...) + local previous, phase, priorBefore = token.executingUI, token.uiPhase, token.uiBefore + local before = snapshot() + token.executingUI, token.uiPhase, token.uiBefore = state, state.asking, before + local result = pack(engine(fn, ...)) + discover(before, step) + service.refresh() + token.executingUI, token.uiPhase, token.uiBefore = previous, phase, priorBefore + if not result[1] then + token.finish(false, "native shop callback failed: " .. message(result[2])) + return + end + return unpack(result, 2, result.n) + end + token.shop, token.nativeStep = service, step + local function done() + if not root or complete or token.step ~= step or not check() or contains(token.stack, root) then + return + end + complete = true + end + local called, state = engine(require("src.ui.Screens").push, game, "ShopMenu", stock, done) + if not called then + token.shop, token.nativeStep = nil, nil + error(state, 0) + end + root = state + if token.done then + remove(token.stack, root) + return + end + owned(root, step) + if not check() then + return + end + runner.waitingCheck = function() + return complete + end + runner:yield() + token.shop, token.nativeStep = nil, nil + end local function battle(ctx, row) local verb = row[1] if verb == "check_battle_result" then @@ -970,7 +1079,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if Battles.ARITY[verb] then + if Shops.ARITY[verb] then + shop(ctx, row) + elseif Battles.ARITY[verb] then battle(ctx, row) elseif Travel.ARITY[verb] then warp(ctx, row) diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 7b859ce02..89b6c948b 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -2,6 +2,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") local Movement = require("src.mods.ScriptMovement") @@ -40,14 +41,22 @@ end for verb, arity in pairs(Battles.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(Shops.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) if type(verb) ~= "string" then return false end if - (WorldEdits.ARITY[verb] or Movement.ARITY[verb] or Travel.ARITY[verb] or Battles.ARITY[verb]) - and require("src.core.GameVersion").generation() > 3 + ( + WorldEdits.ARITY[verb] + or Movement.ARITY[verb] + or Travel.ARITY[verb] + or Battles.ARITY[verb] + or Shops.ARITY[verb] + ) and require("src.core.GameVersion").generation() > 3 then return false end @@ -133,12 +142,19 @@ function Program.compile(raw, context) and not Movement.ARITY[verb] and not Travel.ARITY[verb] and not Battles.ARITY[verb] + and not Shops.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if Battles.ARITY[verb] then + if Shops.ARITY[verb] then + local value, reason = Shops.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Battles.ARITY[verb] then local value, reason = Battles.syntax(verb, source, context) if not value then return fail(i, reason) diff --git a/src/mods/ScriptShopUI.lua b/src/mods/ScriptShopUI.lua new file mode 100644 index 000000000..5ed099c32 --- /dev/null +++ b/src/mods/ScriptShopUI.lua @@ -0,0 +1,61 @@ +-- Callback leases for the native mart's menu, quantity and confirmation phases. +-- Only explicit UI fields are visited, never a state's Game/save/data graph. +local U = {} +local methods = { "select", "choose", "cancel", "close", "buy", "sell", "askQuantity", "askQuantityInput" } +local callbacks = + { onDone = true, onChoose = true, onCancel = true, onQuit = true, onSelect = true, choice = true } +function U.new(control) + local wrapped = setmetatable({}, { __mode = "k" }) + local function bind(state, holder, key, once, phase) + local original = holder[key] + if type(original) ~= "function" or wrapped[original] then + return + end + local fired = false + local guarded + guarded = function(...) + if fired or holder[key] ~= guarded or not control.alive(state) or phase and not phase() then + return + end + if once then + fired = true + end + return control.invoke(state, original, ...) + end + holder[key] = guarded + wrapped[guarded] = true + end + local function attach(state) + for key in pairs(callbacks) do + if rawget(state, key) ~= nil then + local once = key == "onDone" + or key == "choice" + or key == "onChoose" and getmetatable(state) == require("src.ui.ChoiceBox") + bind(state, state, key, once) + end + end + for _, key in ipairs(methods) do + bind(state, state, key, false) + end + for _, item in ipairs(state.items or {}) do + if type(item) == "table" then + bind(state, item, "onSelect", false, function() + for _, current in ipairs(state.items or {}) do + if current == item then + return true + end + end + return false + end) + end + end + local asking = state.asking + if type(asking) == "table" then + bind(state, asking, "onDone", true, function() + return state.asking == asking or control.phase(state) == asking + end) + end + end + return attach +end +return U diff --git a/src/mods/ScriptShops.lua b/src/mods/ScriptShops.lua new file mode 100644 index 000000000..f026c2cae --- /dev/null +++ b/src/mods/ScriptShops.lua @@ -0,0 +1,121 @@ +-- Native mart content and save validation; no live UI is created by preflight. +local Ids = require("src.mods.UnifiedIds") +local Version = require("src.core.GameVersion") +local S = { ARITY = { open_mart = 2 } } +local function integer(n, max) + return type(n) == "number" and n == n and n >= 0 and n <= max and n % 1 == 0 +end +function S.syntax(_, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable shops unavailable for this generation" + end + local ref = row[2] + if + #row ~= 2 + or not (type(ref) == "string" and #ref > 0 and #ref <= 256 and not ref:find("%c") or integer(ref, 65535)) + then + return nil, "invalid mart text constant" + end + return { row[1], ref } +end +local function stores(game) + local save = game.save + if + not integer(save.money, 9007199254740991) + or type(save.inventory) ~= "table" + or save.bagOrder ~= nil and type(save.bagOrder) ~= "table" + then + return nil, "invalid shop wallet/inventory" + end + local total, room = 0, 9007199254740991 - save.money + for id, count in pairs(save.inventory) do + total = total + 1 + if total > 4096 then + return nil, "shop inventory too large" + end + if not integer(count, 999999) then + return nil, "invalid inventory count" + end + local item = game.data.items and game.data.items[id] + if + item + and not item.keyItem + and not require("src.inventory.ItemEffects").alias(id, item):find("^HM_") + then + if not integer(item.price, 999999) then + return nil, "invalid inventory item price" + end + local value = math.floor(item.price / 2) * count + if value > room then + return nil, "shop money overflow" + end + room = room - value + end + end + return true +end +function S.plan(loader, game, world, row) + local valid, why = stores(game) + if not valid then + return nil, why + end + local def = world.map and world.map.def + if not (def and type(def.label) == "string" and type(game.data.textEntry) == "function") then + return nil, "native mart content unavailable" + end + local entry = game.data:textEntry(def.label, row[2]) + local list = entry and entry.mart + if type(list) ~= "table" or #list == 0 or #list > 99 then + return nil, "native mart content unavailable: " .. tostring(row[2]) + end + for k in pairs(list) do + if not integer(k, #list) or k == 0 then + return nil, "invalid mart stock" + end + end + local ids = Ids.new(loader) + local stock, records = {}, {} + for i = 1, #list do + local public = ids:read("items", list[i]) + local group, reason = ids:resolve("items", public) + local item = group and game.data.items and game.data.items[group.key] + if not item then + return nil, reason or "unknown mart item" + end + if not integer(item.price, 999999) or type(item.name) ~= "string" then + return nil, "invalid mart item price/name" + end + stock[i] = group.key + records[group.key] = { record = item, price = item.price } + end + local inventory = game.save.inventory + for id in pairs(inventory) do + local item = game.data.items[id] + if item then + records[id] = { record = item, price = item.price } + end + end + return stock, + function() + local ok, reason = stores(game) + if not ok then + return nil, reason + end + if game.save.inventory ~= inventory then + return nil, "shop inventory changed" + end + for id in pairs(inventory) do + local item = game.data.items[id] + if item and not records[id] then + records[id] = { record = item, price = item.price } + end + end + for id, expected in pairs(records) do + if game.data.items[id] ~= expected.record or expected.record.price ~= expected.price then + return nil, "shop item data changed" + end + end + return true + end +end +return S diff --git a/src/mods/ScriptTravel.lua b/src/mods/ScriptTravel.lua index 31f56630c..5a6ec345b 100644 --- a/src/mods/ScriptTravel.lua +++ b/src/mods/ScriptTravel.lua @@ -1,4 +1,5 @@ -- Destination preflight, including later world rows against destination data. +local Shops = require("src.mods.ScriptShops") local Warp = require("src.mods.Api3Warp") local Movement = require("src.mods.ScriptMovement") local Edits = require("src.mods.ScriptWorldEdits") @@ -52,7 +53,7 @@ local function future(game, world, plan, cache) } return proxy, view end -function T.prepare(game, world, owner, rows) +function T.prepare(game, world, owner, rows, loader) local gen = Version.generation() local model, modelGame = world, game local cache = {} @@ -69,6 +70,11 @@ function T.prepare(game, world, owner, rows) end modelGame, model = future(game, world, plan, cache) end + elseif Shops.ARITY[verb] then + local stock, why = Shops.plan(loader, modelGame, model, row) + if not stock then + return nil, "row " .. i .. ": " .. why + end elseif Edits.ARITY[verb] then local edit, why = Edits.prepare(modelGame, model, owner, { row }) if not edit then diff --git a/tests/api3_contract/mods/script_shops/main.lua b/tests/api3_contract/mods/script_shops/main.lua new file mode 100644 index 000000000..2d21c725b --- /dev/null +++ b/tests/api3_contract/mods/script_shops/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success, reason = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok, why) + done, success, reason = done + 1, ok, why + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, reason = reason } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_shops/manifest.json b/tests/api3_contract/mods/script_shops/manifest.json new file mode 100644 index 000000000..d53ea01c5 --- /dev/null +++ b/tests/api3_contract/mods/script_shops/manifest.json @@ -0,0 +1 @@ +{"id":"script_shops","name":"Portable shops","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_gifts.lua b/tests/api3_contract/script_gifts.lua index 9aee71bb0..9545bb462 100644 --- a/tests/api3_contract/script_gifts.lua +++ b/tests/api3_contract/script_gifts.lua @@ -1,7 +1,7 @@ local M = {} -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_gifts%.lua$") - local f = dofile(codeRoot .. "/script_state.lua").fixture(root, backend, version, "script_gifts") + local f = dofile(codeRoot .. "/script_state.lua").fixture(root, backend, version, modId or "script_gifts") love = love or require("tests.love_stub") local data, save = f.game.data, f.game.save data.text = data.text or {} diff --git a/tests/api3_contract/script_shops.lua b/tests/api3_contract/script_shops.lua new file mode 100644 index 000000000..adde51e2a --- /dev/null +++ b/tests/api3_contract/script_shops.lua @@ -0,0 +1,83 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_shops%.lua$") + local f = dofile(codeRoot .. "/script_gifts.lua").fixture(root, backend, version, "script_shops") + local data = f.game.data + f.ow.game, f.ow.text = f.game, data.text + f.ow.map.def.label = "SHOP_HOME" + data.text_pointers = { SHOP_HOME = { SHOP = { mart = { "POTION" } } } } + if data.items and data.items.POTION then + data.items.POTION.price = 100 + end + if backend == "gen1" and f.gen == 3 then + require("src.core.game3.scripting.space").ensureBundle = function() + local IR = require("src.core.game3.scripting.text_ir") + return { + text = { + Text_MayIHelpYou = IR.fromAscii("WELCOME"), + gText_HowMayIServeYou = IR.fromAscii("WELCOME"), + }, + } + end + end + if backend == "gen1" and f.gen == 2 then + f.ow.openMart = require("src.world.gen2.World").openMart + end + function f:tick(key) + self.game.input.wasPressed = function(_, button) + return button == key + end + self.loader.hooks:call(backend == "gen1" and "core.update" or "input.step", function() + if backend == "gen2" then + self.ow.runner:update() + end + end, self.game, 1 / 60) + self.game.stack:update(1 / 60) + self.game.input.wasPressed = function() + return false + end + end + function f:close() + if backend == "gen1" and f.gen == 3 then + require("src.ui.game3.shop_menu").close() + else + for _ = 1, 2000 do + if self.exports.result().done > 0 then + return + end + self:tick("b") + end + error("native shop did not close") + end + end + function f:drain() + for _ = 1, 100 do + if self.exports.result().done > 0 then + return + end + self:tick() + end + error("shop script did not finish") + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local ok, why = f.exports.queue({ { "open_mart", "SHOP" }, { "set_flag", "mod:after" } }) + assert(ok, why) + assert( + f.exports.result().done == 0 and not f.exports.get("mod:after"), + require("src.link.Json").encode(f.exports.result()) .. " " .. table.concat(f.loader.errors, "; ") + ) + f:close() + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + local report = { complete = true, quantity = f.quantity() } + f = M.fixture(root, backend, version) + local value, reason = f.exports.queue({ { "set_flag", "mod:prefix" }, { "open_mart", "UNKNOWN" } }) + assert(value == nil and type(reason) == "string" and not f.exports.get("mod:prefix")) + report.preflight = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 947f910c3..ed7ebf365 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -24,11 +24,14 @@ assert( or mode == "script_travel" or mode == "script_battles" or mode == "script_trainers" + or mode == "script_shops" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_trainers" then +if mode == "script_shops" then + dofile(codeRoot .. "/script_shops.lua").run(root, backend, version) +elseif mode == "script_trainers" then dofile(codeRoot .. "/script_trainers.lua").run(root, backend, version) elseif mode == "script_battles" then dofile(codeRoot .. "/script_battles.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_shops.lua b/tests/engine/portable_script_shops.lua new file mode 100644 index 000000000..b900ac25f --- /dev/null +++ b/tests/engine/portable_script_shops.lua @@ -0,0 +1,329 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_shops") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v, native) + local f = Contract.fixture("tests/api3_contract", "gen2", v or "red") + f.game.save.money = 1000 + if native then + f.game.data.constants = f.game.data.constants or {} + f.game.data.constants.gen3MartText = { buy = "BUY", sell = "SELL", quit = "QUIT" } + end + return f +end +local function queue(f) + return f.exports.queue({ { "open_mart", "SHOP" }, { "set_flag", "mod:after" } }) +end +local function finishChoice(f) + f:tick("a") + for _ = 1, 20 do + f:tick() + end +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared shop contract: " .. tostring(why)) +end +for _, change in ipairs({ + "unknown", + "invalid_price", + "unknown_item", + "empty", + "sparse", + "wallet", + "inventory", + "overflow", +}) do + local f = fixture() + local ref = "SHOP" + if change == "unknown" then + ref = "UNKNOWN" + elseif change == "invalid_price" then + f.game.data.items.POTION.price = -1 + elseif change == "unknown_item" then + f.game.data.text_pointers.SHOP_HOME.SHOP.mart = { "UNKNOWN" } + elseif change == "empty" then + f.game.data.text_pointers.SHOP_HOME.SHOP.mart = {} + elseif change == "sparse" then + f.game.data.text_pointers.SHOP_HOME.SHOP.mart[3] = "POTION" + elseif change == "wallet" then + f.game.save.money = false + elseif change == "inventory" then + f.game.save.inventory.POTION = -1 + else + f.game.save.money = 9007199254740991 + end + T.eq( + f.exports.queue({ { "set_flag", "mod:prefix" }, { "open_mart", ref } }), + nil, + "invalid shop preflight: " .. change + ) + T.eq(f.exports.get("mod:prefix"), false, "invalid suffix prevents prefix") + T.eq(f.game.stack:top(), f.ow, "preflight opens no native UI") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local f = fixture(v) + T.check(queue(f), v .. " native classic shop admitted") + local root = f.game.stack:top() + f:tick("a") + local list = f.game.stack:top() + T.eq(getmetatable(list), require("src.ui.ListMenu"), "native buy list opened") + f:tick("a") + local quantity = f.game.stack:top() + local oldQuantity = quantity.onDone + T.eq(getmetatable(quantity), require("src.ui.QuantityBox"), "native quantity picker opened") + f:tick("a") + local choice = f.game.stack:top() + local oldChoice = choice.onChoose + finishChoice(f) + T.eq(f.quantity(), 4, "native bag receives purchase") + T.eq(f.game.save.money, 900, "native wallet charged once") + oldQuantity(99) + oldChoice(true) + T.eq(f.quantity(), 4, "old quantity/confirm callbacks cannot replay purchase") + T.eq(f.game.save.money, 900, "old callbacks cannot charge again") + T.eq(f.game.stack:top(), list, "nested callbacks retain native list") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "shop quit resumes script") + T.eq(f.exports.get("mod:after"), true, "script runs after native close") + root.onCancel() + oldQuantity(1) + oldChoice(true) + T.eq(f.game.stack:top(), f.ow, "finished UI cannot reopen a child") +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + T.check(queue(f), "native sale admitted") + f.game.stack:top().index = 2 + f:tick("a") + f:tick("a") + local quantity = f.game.stack:top() + quantity.qty = 3 + f:tick("a") + finishChoice(f) + T.eq(f.quantity(), 0, "native sale removes full item stack") + T.eq(f.game.save.money, 1150, "native sale credits half price") + T.eq(#f.game.save.bagOrder, 0, "native sale removes stale bag order entry") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "native sale closes normally") +end +for _, change in ipairs({ "rollback", "save", "data", "price", "inventory", "removed" }) do + local f = fixture() + T.check(queue(f), "shop before live change") + f:tick("a") + f:tick("a") + local quantity = f.game.stack:top() + local callback = quantity.onDone + local overlay = {} + f.game.stack:push(overlay) + if change == "rollback" then + f.loader:_rollback("script_shops") + elseif change == "price" then + f.game.data.items.POTION.price = 200 + elseif change == "inventory" then + f.game.save.inventory = { POTION = 3 } + elseif change == "removed" then + table.remove(f.game.stack.states, 2) + else + f.game[change] = {} + end + f.loader.hooks:call("input.step", function() end, f.game, 1 / 60) + T.eq(f.exports.result().success, false, "live context/item change cancels shop") + T.eq(f.game.stack:top(), overlay, "foreign overlay survives shop cancellation") + T.eq(#f.game.stack.states, 2, "only owned nested screens removed") + callback(1) + T.eq(#f.game.stack.states, 2, "stale quantity cannot open a confirmation") +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v, true) + T.check(queue(f), v .. " native Gen3 counter admitted") + local root = f.game.stack:top() + f:tick("a") + local counter = f.game.stack:top() + T.eq(getmetatable(counter), require("src.ui.Gen3ShopMenu"), "Gen3 buy counter selected") + f:tick("a") + local asking = counter.asking + local oldQuantity = asking and asking.onDone + T.check(asking, "native Gen3 inline quantity opened") + f:tick("a") + local box = f.game.stack:top() + local oldChoice = box.choice + for _ = 1, 2000 do + f:tick("a") + if f.quantity() == 4 then + break + end + end + T.eq(f.quantity(), 4, "native Gen3 confirmation purchases item") + T.eq(f.game.save.money, 900, "native Gen3 wallet charged once") + oldQuantity(5) + oldChoice(true) + T.eq(f.quantity(), 4, "old Gen3 inline callbacks cannot replay") + T.eq(f.game.save.money, 900, "old Gen3 callbacks cannot charge again") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "native Gen3 counter closes") +end +-- A retained confirmation is inert under a foreign overlay, then the native +-- answer still works after that overlay closes. +do + local f = fixture() + T.check(queue(f), "shop before foreign confirmation overlay") + f:tick("a") + f:tick("a") + f:tick("a") + local choice = f.game.stack:top() + local callback = choice.onChoose + local overlay = {} + f.game.stack:push(overlay) + callback(true) + T.eq(f.quantity(), 3, "foreign overlay blocks direct confirmation") + T.eq(f.game.save.money, 1000, "foreign overlay blocks debit") + f.game.stack:pop() + finishChoice(f) + T.eq(f.quantity(), 4, "legitimate answer still works after overlay") + f:close() + f:drain() +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v, true) + T.check(queue(f), "Gen3 quantity before cancellation") + f:tick("a") + f:tick("a") + local counter = f.game.stack:top() + local old = counter.asking.onDone + f:tick("b") + old(1) + T.eq(f.game.stack:top(), counter, "cancelled inline quantity cannot create confirmation") + T.eq(f.quantity(), 3, "cancelled inline quantity cannot give item") + f:tick("a") + local current = counter.asking + old(1) + T.eq(counter.asking, current, "old inline callback cannot replace a new request") + f:close() + f:drain() +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v, true) + T.check(queue(f), "Gen3 native sale admitted") + f.game.stack:top().index = 2 + f:tick("a") + f:tick("a") + local quantity = f.game.stack:top() + if v == "firered" then + T.eq(getmetatable(quantity), require("src.ui.Gen3ShopMenu"), "FireRed native inline sale quantity") + T.check(quantity.asking, "FireRed sale quantity is active") + quantity.asking.qty = 2 + else + T.eq(getmetatable(quantity), require("src.ui.QuantityBox"), "Emerald native bag sale quantity") + quantity.qty = 2 + end + f:tick("a") + for _ = 1, 2000 do + f:tick("a") + if f.quantity() == 1 then + break + end + end + T.eq(f.quantity(), 1, "Gen3 native sale removes selected quantity") + T.eq(f.game.save.money, 1100, "Gen3 native sale credits wallet") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "Gen3 native sale returns to script") +end +for _, phase in ipairs({ "classic_quantity", "classic_choice", "gen3_quantity", "gen3_choice" }) do + local native = phase:find("gen3", 1, true) ~= nil + local f = fixture(native and "emerald" or "red", native) + T.check(queue(f), "native shop before phase unload") + f:tick("a") + f:tick("a") + local callbacks = {} + if native then + callbacks[#callbacks + 1] = f.game.stack:top().asking.onDone + else + callbacks[#callbacks + 1] = f.game.stack:top().onDone + end + if phase:find("choice", 1, true) then + f:tick("a") + local state = f.game.stack:top() + callbacks[#callbacks + 1] = native and state.choice or state.onChoose + end + local overlay = {} + f.game.stack:push(overlay) + f.loader:_rollback("script_shops") + for _, fn in ipairs(callbacks) do + fn(1) + fn(true) + end + T.eq(f.game.stack:top(), overlay, "unload keeps foreign overlay") + T.eq(#f.game.stack.states, 2, "unload removes exact native shop screens") + T.eq(f.quantity(), 3, "retired purchase callback cannot grant item") + T.eq(f.game.save.money, 1000, "retired purchase callback cannot debit wallet") +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + f.game.data.maps[f.dest].label = "SHOP_DEST" + f.game.data.text_pointers.SHOP_DEST = { DEST_SHOP = { mart = { "POTION" } } } + T.check( + f.exports.queue({ { "warp", f.dest, 1, 1 }, { "open_mart", "DEST_SHOP" }, { "set_flag", "mod:after" } }), + "destination shop preflight" + ) + f:arrive() + f:complete() + f:tick() + T.check(f.game.stack:top() ~= f.ow, "destination native shop opens after arrival") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "destination native shop resumes program") + f = fixture(v) + f.game.data.maps[f.dest].label = "SHOP_DEST" + f.game.data.text_pointers.SHOP_DEST = { DEST_SHOP = { mart = { "UNKNOWN" } } } + T.eq( + f.exports.queue({ { "warp", f.dest, 1, 1 }, { "open_mart", "DEST_SHOP" } }), + nil, + "invalid destination stock refuses whole script" + ) + T.eq(#f.trace, 0, "invalid future stock prevents warp") +end +for _, change in ipairs({ "wallet", "inventory", "stock" }) do + local f = fixture() + T.check(f.exports.queue({ { "wait", 1 }, { "open_mart", "SHOP" } }), "deferred shop admitted") + if change == "wallet" then + f.game.save.money = false + elseif change == "inventory" then + f.game.save.inventory = false + else + f.game.data.items.POTION.price = -1 + end + f:drain() + T.eq(f.exports.result().success, false, "live shop stores revalidated") + T.eq(f.game.stack:top(), f.ow, "bad live data opens no native shop") +end +do + local f = fixture() + local overlay = {} + local Screens = require("src.ui.Screens") + f.game.data.screens = { + ShopMenu = { + new = function(game, stock, onQuit) + f.loader:_rollback("script_shops") + game.stack:push(overlay) + return require("src.ui.ShopMenu").new(game, stock, onQuit) + end, + }, + } + Screens.invalidate() + T.check(queue(f), "shop admitted before constructor hook retires owner") + T.eq(f.exports.result().success, false, "constructor retirement cancels program") + T.eq(f.game.stack:top(), overlay, "constructor retirement preserves foreign overlay") + T.eq(#f.game.stack.states, 2, "late native root removed after constructor returns") + Screens.invalidate() +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script shops") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 250603752..bf9cbdf38 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 1fa9d59d90fbb6391a4a6cf994de56c016f40436 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:38 +0200 Subject: [PATCH 28/71] Add validated portable sound and music script commands --- docs/api3-portability.md | 2 +- docs/portable-world-scripts.md | 27 ++ src/core/Sound.lua | 32 +- src/mods/PortableScripts.lua | 32 +- src/mods/ScriptAudio.lua | 152 +++++++++ src/mods/ScriptProgram.lua | 14 +- .../api3_contract/mods/script_audio/main.lua | 15 + .../mods/script_audio/manifest.json | 1 + tests/api3_contract/script_audio.lua | 154 ++++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_audio.lua | 288 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 12 files changed, 713 insertions(+), 11 deletions(-) create mode 100644 src/mods/ScriptAudio.lua create mode 100644 tests/api3_contract/mods/script_audio/main.lua create mode 100644 tests/api3_contract/mods/script_audio/manifest.json create mode 100644 tests/api3_contract/script_audio.lua create mode 100644 tests/engine/portable_script_audio.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 55dbe5fee..4e2861f8b 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -135,7 +135,7 @@ loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. Eight object/block cases and eight NPC cases extend this to 96 comparisons -and 288 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +and 304 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index e20c5d4d2..6a26982f7 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -434,3 +434,30 @@ callbacks run. Committed native transactions remain committed after cancellation. The same guard covers classic lists, FireRed's inline sale quantity and Emerald's native sell bag. Closed UI records are released between frames rather than accumulated for a long shopping session. + +## Sound and music + +Gen1–3 accept `play_sound(id)`, `wait_sound()`, +`play_music(id, {keep=boolean, reason=string}?)`, `stop_music()`, +`fade_music(control?)`, `play_once(id)` and `play_default_music()`. +The active game's native audio definitions must contain the cue. All rows +are checked before a prefix runs and checked again at dispatch. Gen3 +numeric and extracted `musName`/`sfxName` aliases resolve to an unambiguous +native sequence; unknown aliases are refused. Gen3 `play_music(0)` retains +the native stop convention. `play_once("Get_Item1")` can resolve the native +Gen3 obtain-item fanfare role. + +Playback uses the existing Sound/Music controllers. `wait_sound` waits for +native SFX activity and its last returned source, with the reference's +600-foreground-frame release ceiling. A one-shot waits on native restore +state, releases if another song takes over, and fails after 3,600 foreground +frames if it stalls. Foreign overlays pause continuation. Music `keep` is +armed only while the script still owns the context. Fade control is a whole +number 0–255 (default 10); timing follows this host's native fade controller. + +Names/numbers remain game-specific unless the mod supplies a shared cue. +The reference Gen3 resolver can return an unknown string unchanged; this +host requires a real definition before admission. Missing audio data is +therefore a preflight failure; a muted/unavailable device keeps the native +headless playback behavior. Already started audio remains engine-owned on +script cancellation. Dialogue-bound cries/fanfares are a separate slice. diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 7093a32f5..a6e84df7e 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -264,14 +264,34 @@ end -- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it -- Resolve a cartridge effect by its extracted native name, including effects -- that have no shared engine role in audio.sfx. -function Sound.playNamedEffect(data,name) - for id,song in pairs(data and data.audio and data.audio.songs or {}) do - if type(song)=="table" and song.sfxName==name and tonumber(song.index) then - local src=playPath(data,id,{song=id,m4a=tonumber(song.index)}) - if src then played("sfx",name) end - return src +-- Native numeric/symbolic sequence aliases must resolve unambiguously. +function Sound.sequenceKey(data, name) + if type(name) ~= "string" and type(name) ~= "number" then return nil end + local songs = data and data.audio and data.audio.songs or {} + if (type(name) == "string" or type(name) == "number") and (type(songs[name]) == "string" or type(songs[name]) == "table") then + return name, songs[name] + end + local key, found + for id, song in pairs(songs) do + if type(song) == "table" and (song.sfxName == name or song.musName == name + or type(name) == "number" and song.index == name) then + if found and found ~= song then return nil, "ambiguous native audio id" end + key, found = id, song end end + return key, found +end + +function Sound.playNamedEffect(data, name) + local id, song = Sound.sequenceKey(data, name) + if not id then return nil end + local def = song + if type(song) == "table" and tonumber(song.index) and not song.file then + def = { song = id, m4a = tonumber(song.index) } + end + local src = playPath(data, id, def) + if src then played("sfx", name) end + return src end function Sound.play(data, name) diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index a3b56aa46..9fca0ef2d 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -5,6 +5,7 @@ local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Audio = require("src.mods.ScriptAudio") local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") @@ -286,6 +287,10 @@ local function facade(loader, modId, native) end end end + local audio, audioReason = Audio.prepare(game, program) + if not audio then + return nil, audioReason + end local state, reason = State.prepare(loader, game, ow, program) if not state then return nil, reason @@ -1056,6 +1061,29 @@ local function facade(loader, modId, native) end end end + local function waitAudio(poll, ceiling, continueOnTimeout) + if poll() then + return + end + local remaining = ceiling + runner.waitingCheck = function() + if not check() then + return false + end + if poll() then + return true + end + remaining = remaining - 1 + if remaining <= 0 then + if continueOnTimeout then + return true + end + token.finish(false, "native audio timed out") + end + return false + end + runner:yield() + end function runner:exec(compiled, ctx) local pc, slice = 1, 0 while pc <= #compiled do @@ -1079,7 +1107,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if Shops.ARITY[verb] then + if Audio.ARITY[verb] then + audio(ctx, row, waitAudio, check) + elseif Shops.ARITY[verb] then shop(ctx, row) elseif Battles.ARITY[verb] then battle(ctx, row) diff --git a/src/mods/ScriptAudio.lua b/src/mods/ScriptAudio.lua new file mode 100644 index 000000000..b02767bfd --- /dev/null +++ b/src/mods/ScriptAudio.lua @@ -0,0 +1,152 @@ +-- Portable native audio cues, with pure data lookup and bounded waits. +local Version = require("src.core.GameVersion") +local A = { + ARITY = { + play_sound = 2, + wait_sound = 1, + play_music = 3, + stop_music = 1, + fade_music = 2, + play_once = 2, + play_default_music = 1, + }, +} +local function integer(v, max) + return type(v) == "number" and v == v and v >= 0 and v <= max and v % 1 == 0 +end +local function id(v) + return type(v) == "string" and #v > 0 and #v <= 256 and not v:find("%c") or integer(v, 65535) +end +function A.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable audio unavailable for this generation" + end + local minimum = (verb == "play_music" and 2 or verb == "fade_music" and 1) or A.ARITY[verb] + if #row < minimum or #row > A.ARITY[verb] then + return nil, "invalid command arity" + end + local out = { row[1], row[2] } + if verb == "fade_music" then + if row[2] ~= nil and not integer(row[2], 255) then + return nil, "invalid music fade" + end + elseif verb == "play_sound" or verb == "play_music" or verb == "play_once" then + if not id(row[2]) then + return nil, "invalid audio id" + end + end + if verb == "play_music" and row[3] ~= nil then + if type(row[3]) ~= "table" or getmetatable(row[3]) ~= nil then + return nil, "invalid music options" + end + out[3] = {} + for k, v in pairs(row[3]) do + if + not ( + k == "keep" and type(v) == "boolean" + or k == "reason" and type(v) == "string" and #v <= 256 and not v:find("%c") + ) + then + return nil, "invalid music option" + end + out[3][k] = v + end + end + return out +end +function A.resolve(game, row) + local verb, ref = row[1], row[2] + if Version.generation() == 3 and verb == "play_music" and ref == 0 then + return { stop = true } + end + local audio = game.data.audio or {} + local sound = verb == "play_sound" + local rows = sound and audio.sfx or audio.songs + if rows and (type(rows[ref]) == "table" or type(rows[ref]) == "string") then + return { key = ref, sound = sound } + end + if Version.generation() == 3 and (sound or verb == "play_music" or verb == "play_once") then + if verb == "play_once" and ref == "Get_Item1" then + local def = audio.sfx and audio.sfx.Get_Item1 + if type(def) == "table" then + ref = def.song or def.m4a or ref + end + end + local key, def = require("src.core.Sound").sequenceKey(game.data, ref) + if key then + return { key = key, sound = sound, sequence = sound } + end + if def then + return nil, def + end + end + return nil, "native audio unavailable: " .. tostring(row[2]) +end +function A.prepare(game, rows) + for i, row in ipairs(rows) do + if row[1] == "play_sound" or row[1] == "play_music" or row[1] == "play_once" then + local plan, why = A.resolve(game, row) + if not plan then + return nil, "row " .. i .. ": " .. why + end + end + end + return function(ctx, row, wait, alive) + local verb = row[1] + local Sound, Music = require("src.core.Sound"), require("src.core.Music") + if verb == "stop_music" then + Music.stop() + elseif verb == "fade_music" then + Music.fadeOut(row[2] or 10) + elseif verb == "play_default_music" then + local ow = ctx.overworld + Music.playMap(game.data, ow.map.id, game.save.onBike, ow.player.surfing) + elseif verb == "wait_sound" then + local source = ctx.lastSfxSrc + ctx.lastSfxSrc = nil + local function busy() + if Sound.anyPlaying("sfx") then + return true + end + if not source then + return false + end + local ok, value = pcall(function() + return source:isPlaying() + end) + return ok and value == true + end + if busy() then + wait(function() + return not busy() + end, 600, true) + end + else + local plan, why = A.resolve(game, row) + if not plan then + error(why, 0) + end + if verb == "play_sound" then + if plan.sequence then + ctx.lastSfxSrc = Sound.playNamedEffect(game.data, plan.key) + else + ctx.lastSfxSrc = Sound.play(game.data, plan.key) + end + elseif verb == "play_music" then + if plan.stop then + Music.stop() + return + end + Music.play(game.data, plan.key, nil, row[3]) + if alive() and row[3] and row[3].keep then + ctx.overworld.keepMusicOnce = true + end + elseif Music.playOnce(game.data, plan.key) then + wait(function() + return Music.current() ~= plan.key or not Music.oneShotPlaying() + end, 3600, false) + end + end + end +end +return A diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 89b6c948b..1e6b6238c 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -2,6 +2,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Audio = require("src.mods.ScriptAudio") local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") @@ -44,6 +45,9 @@ end for verb, arity in pairs(Shops.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(Audio.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) if type(verb) ~= "string" then @@ -56,6 +60,7 @@ function Program.supports(verb) or Travel.ARITY[verb] or Battles.ARITY[verb] or Shops.ARITY[verb] + or Audio.ARITY[verb] ) and require("src.core.GameVersion").generation() > 3 then return false @@ -143,12 +148,19 @@ function Program.compile(raw, context) and not Travel.ARITY[verb] and not Battles.ARITY[verb] and not Shops.ARITY[verb] + and not Audio.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if Shops.ARITY[verb] then + if Audio.ARITY[verb] then + local value, reason = Audio.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Shops.ARITY[verb] then local value, reason = Shops.syntax(verb, source, context) if not value then return fail(i, reason) diff --git a/tests/api3_contract/mods/script_audio/main.lua b/tests/api3_contract/mods/script_audio/main.lua new file mode 100644 index 000000000..2d21c725b --- /dev/null +++ b/tests/api3_contract/mods/script_audio/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success, reason = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok, why) + done, success, reason = done + 1, ok, why + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, reason = reason } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_audio/manifest.json b/tests/api3_contract/mods/script_audio/manifest.json new file mode 100644 index 000000000..c4f8be8ba --- /dev/null +++ b/tests/api3_contract/mods/script_audio/manifest.json @@ -0,0 +1 @@ +{"id":"script_audio","name":"Portable audio","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_audio.lua b/tests/api3_contract/script_audio.lua new file mode 100644 index 000000000..1f957bd0f --- /dev/null +++ b/tests/api3_contract/script_audio.lua @@ -0,0 +1,154 @@ +-- Real loaders/adapters; an explicit source/clock seam avoids audio-device timing. +local M = {} +function M.fixture(root, backend, version, modId) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_audio%.lua$") + local f = dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, modId or "script_audio") + f.game.data.audio = { + songs = { TUNE = { file = "tune.wav" }, JINGLE = { file = "jingle.wav" } }, + sfx = { CHIME = { file = "chime.wav" } }, + mapSongs = { [f.home] = "TUNE" }, + } + f.audioTrace = {} + f.source = { playing = false } + function f.source:isPlaying() + return self.playing + end + local Sound, Music = require("src.core.Sound"), require("src.core.Music") + local restored = {} + local function patch(module, key, fn) + local old = module[key] + restored[#restored + 1] = function() + module[key] = old + end + module[key] = fn + end + local function sound(_, key) + f.audioTrace[#f.audioTrace + 1] = { kind = "sound", id = key } + f.source.playing = true + return f.source + end + local function music(_, key, _, opts) + f.audioTrace[#f.audioTrace + 1] = { kind = "music", id = key, keep = opts and opts.keep or false } + f.current = key + end + local function stop() + f.audioTrace[#f.audioTrace + 1] = { kind = "stop" } + f.current = nil + f.musicBusy = false + end + local function once(_, key) + f.audioTrace[#f.audioTrace + 1] = { kind = "once", id = key } + f.current = key + f.musicBusy = true + return true + end + local function fade(control) + f.audioTrace[#f.audioTrace + 1] = { kind = "fade", control = control } + end + local function default(_, map) + f.audioTrace[#f.audioTrace + 1] = { kind = "default", map = map } + end + patch(Sound, "play", sound) + patch(Sound, "playNamedEffect", sound) + patch(Sound, "sfxBusy", function() + return f.otherBusy == true + end) + patch(Sound, "anyPlaying", function() + return f.otherBusy == true + end) + patch(Music, "play", music) + patch(Music, "stop", stop) + patch(Music, "playOnce", once) + patch(Music, "oneShotPlaying", function() + return f.musicBusy + end) + patch(Music, "current", function() + return f.current + end) + patch(Music, "fadeOut", fade) + patch(Music, "playMap", default) + function f.ow:playMapMusic() + default(f.game.data, self.map.id) + end + if backend == "gen1" and f.gen == 3 then + local Audio = require("src.core.game3.audio") + patch(Audio, "songInfo", function(key) + return f.game.data.audio.songs[key] or f.game.data.audio.sfx[key] + end) + patch(Audio, "resolveSong", function(key) + return key + end) + patch(Audio, "playSe", function(key) + return sound(f.game.data, key) + end) + patch(Audio, "isSePlaying", function() + return f.source.playing or f.otherBusy == true + end) + patch(Audio, "playSong", function(key, opts) + if key == 0 then + return stop() + end + return music(f.game.data, key, nil, opts) + end) + patch(Audio, "playFanfare", function(key) + return once(f.game.data, key) + end) + patch(Audio, "isFanfareFinished", function() + return not f.musicBusy + end) + patch(Audio, "fadeOutBgm", fade) + patch(Audio, "restoreMapSong", function() + return default(f.game.data, f.ow.map.id) + end) + end + f.restore = function() + for i = #restored, 1, -1 do + restored[i]() + end + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "play_sound", "CHIME" }, + { "wait_sound" }, + { "play_music", "TUNE", { keep = true } }, + { "fade_music", 2 }, + { "stop_music" }, + { "play_once", "JINGLE" }, + { "play_default_music" }, + { "set_flag", "mod:after" }, + })) + for _ = 1, 3 do + f:tick() + end + assert(f.exports.result().done == 0 and #f.audioTrace == 1) + f.source.playing = false + f.otherBusy = true + for _ = 1, 3 do + f:tick() + end + assert(#f.audioTrace == 1 and f.exports.result().done == 0) + f.otherBusy = false + for _ = 1, 20 do + f:tick() + if f.musicBusy then + break + end + end + assert(f.musicBusy and f.exports.result().done == 0) + f.musicBusy = false + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + local report = { trace = f.audioTrace, complete = true } + f.restore() + f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "play_sound", "CHIME" }, { "play_music", "" } }) + assert(value == nil and type(why) == "string" and #f.audioTrace == 0) + report.preflight = true + f.restore() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index ed7ebf365..f4e4578e1 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -25,11 +25,14 @@ assert( or mode == "script_battles" or mode == "script_trainers" or mode == "script_shops" + or mode == "script_audio" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_shops" then +if mode == "script_audio" then + dofile(codeRoot .. "/script_audio.lua").run(root, backend, version) +elseif mode == "script_shops" then dofile(codeRoot .. "/script_shops.lua").run(root, backend, version) elseif mode == "script_trainers" then dofile(codeRoot .. "/script_trainers.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_audio.lua b/tests/engine/portable_script_audio.lua new file mode 100644 index 000000000..bdc15eb4d --- /dev/null +++ b/tests/engine/portable_script_audio.lua @@ -0,0 +1,288 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_audio") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared audio contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "set_flag", "mod:prefix" }, { "play_sound", "UNKNOWN" } }, + { { "play_music", "UNKNOWN" } }, + { { "play_once", "UNKNOWN" } }, + { { "play_sound", "" } }, + { { "play_music", "TUNE", { keep = "yes" } } }, + { { "play_music", "TUNE", { volume = 1 } } }, + { { "fade_music", -1 } }, + { { "fade_music", 256 } }, + { { "wait_sound", 1 } }, + { { "stop_music", false } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid audio preflight") + T.eq(f.exports.get("mod:prefix"), false, "invalid audio suffix prevents prefix") + T.eq(#f.audioTrace, 0, "preflight plays no cue") + f.restore() +end +for _, phase in ipairs({ "sound", "once" }) do + for _, change in ipairs({ "rollback", "save", "data", "map" }) do + local f = fixture() + local original = f.ow.runner + local first = phase == "sound" and { "play_sound", "CHIME" } or { "play_once", "JINGLE" } + local rows = phase == "sound" and { first, { "wait_sound" }, { "set_flag", "mod:after" } } + or { first, { "set_flag", "mod:after" } } + T.check(f.exports.queue(rows), "audio admitted before context change") + if change == "rollback" then + f.loader:_rollback("script_audio") + elseif change == "map" then + f.ow.map = { id = f.home } + else + f.game[change] = {} + end + f:tick() + f.source.playing = false + f.musicBusy = false + f:tick() + T.eq(f.exports.result().success, false, "retired audio wait fails script") + T.eq(f.exports.result().done, 1, "retired audio finishes once") + T.eq(f.ow.runner, original, "retired audio restores idle runner") + T.eq(#f.audioTrace, 1, "retirement adds no audio or later effects") + f.restore() + end +end +do + local f = fixture() + T.check( + f.exports.queue({ { "play_sound", "CHIME" }, { "wait_sound" }, { "set_flag", "mod:after" } }), + "sound timeout admitted" + ) + for _ = 1, 600 do + f:tick() + end + T.eq(f.exports.result().success, true, "600-frame sound ceiling releases native wait") + T.eq(f.exports.get("mod:after"), true, "sound timeout keeps native continuation behavior") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "play_once", "JINGLE" }, { "set_flag", "mod:after" } }), + "stalled fanfare admitted" + ) + for _ = 1, 3600 do + f:tick() + end + T.eq(f.exports.result().success, false, "stalled one-shot fails at bounded deadline") + T.eq(f.exports.get("mod:after"), false, "stalled one-shot cannot burn later flag") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "play_once", "JINGLE" }, { "set_flag", "mod:after" } }), + "one-shot before foreign music" + ) + f.current = "FOREIGN" + f:drain() + T.eq(f.exports.result().success, true, "one-shot does not wait on a replacement song") + T.eq(f.current, "FOREIGN", "replacement song retained") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "play_sound", "CHIME" }, { "wait_sound" }, { "set_flag", "mod:after" } }), + "sound before overlay" + ) + f.source.playing = false + f.game.stack.states[2] = {} + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.get("mod:after"), false, "audio continuation pauses beneath overlay") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.get("mod:after"), true, "audio resumes in foreground") + f.restore() +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v) + f.game.data.audio.songs.SONG_007 = { index = 7, musName = "MUS_TEST", sfxName = "SE_TEST", tracks = {} } + f.game.data.audio.sfx.Get_Item1 = { m4a = 7, song = "SONG_007" } + T.check( + f.exports.queue({ + { "play_sound", "SE_TEST" }, + { "wait_sound" }, + { "play_music", 7 }, + { + "play_once", + "Get_Item1", + }, + }), + "native Gen3 names/number/fanfare role admitted" + ) + T.eq(f.audioTrace[1].id, "SONG_007", "native sequence resolved for effect") + f.source.playing = false + for _ = 1, 5 do + f:tick() + end + T.eq(f.audioTrace[2].id, "SONG_007", "numeric music id resolved") + T.eq(f.audioTrace[3].id, "SONG_007", "native obtain-item fanfare role resolved") + f.musicBusy = false + f:drain() + T.eq(f.exports.result().success, true, "native Gen3 resolved sequence completes") + f.restore() + f = fixture(v) + f.game.data.audio.songs.A = { index = 7, musName = "MUS_DUP" } + f.game.data.audio.songs.B = { index = 8, musName = "MUS_DUP" } + T.eq( + f.exports.queue({ { "set_flag", "mod:prefix" }, { "play_music", "MUS_DUP" } }), + nil, + "ambiguous sequence name preflight" + ) + T.eq(f.exports.get("mod:prefix"), false, "ambiguous cue prevents prefix") + T.check(f.exports.queue({ { "play_music", 0 } }), "Gen3 native zero music id admitted") + T.eq(f.audioTrace[1].kind, "stop", "Gen3 native zero means stop") + f.restore() +end +do + local f = fixture() + local opts = { keep = true, reason = "script" } + T.check( + f.exports.queue({ { "wait", 1 }, { "play_music", "TUNE", opts } }), + "music options snapshot admitted" + ) + opts.keep = false + f:drain() + T.eq(f.ow.keepMusicOnce, true, "caller cannot mutate queued music options") + f.restore() +end +do + local f = fixture() + T.check(f.exports.queue({ { "wait", 1 }, { "play_music", "TUNE" } }), "deferred cue admitted") + f.game.data.audio.songs.TUNE = nil + f:drain() + T.eq(f.exports.result().success, false, "live missing cue fails dispatch") + T.eq(#f.audioTrace, 0, "missing live cue does not play") + f.restore() +end +do + local f = fixture() + local Music = require("src.core.Music") + local native = Music.play + Music.play = function(...) + native(...) + f.loader:_rollback("script_audio") + end + T.check( + f.exports.queue({ { "play_music", "TUNE", { keep = true } }, { "set_flag", "mod:after" } }), + "retirement during native music admitted" + ) + T.eq(f.exports.result().success, false, "music callback retirement cancels script") + T.eq(f.ow.keepMusicOnce, nil, "retired cue cannot arm a later warp's music flag") + T.eq(#f.audioTrace, 1, "already committed native cue is retained") + f.restore() +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v) + T.eq( + f.exports.queue({ { "set_flag", "mod:prefix" }, { "play_music", "MUS_UNKNOWN" } }), + nil, + "unknown Gen3 cue refused before prefix" + ) + T.eq(f.exports.get("mod:prefix"), false, "Gen3 cue refusal has no prefix") + f.game.data.audio.songs.TUNE = false + T.eq(f.exports.queue({ { "play_music", "TUNE" } }), nil, "disabled cue not accepted as a definition") + f.restore() +end +-- Native Sound file/effect dispatch, with only the actual device replaced. +do + love = require("tests.love_stub") + local Sound = require("src.core.Sound") + local previousAudio = love.audio + local calls = 0 + local src = { + playing = false, + setVolume = function() end, + stop = function(self) + self.playing = false + end, + play = function(self) + self.playing = true + end, + isPlaying = function(self) + return self.playing + end, + } + love.audio = { + newSource = function(file, kind) + T.eq(file, "native-cue.wav", "native file cue passed to device") + calls = calls + 1 + return src + end, + } + local data = { audio = { songs = { PORTABLE_NATIVE_FILE = { file = "native-cue.wav" } } } } + T.eq(Sound.playNamedEffect(data, "PORTABLE_NATIVE_FILE"), src, "native helper returns started source") + T.eq(calls, 1, "native helper builds source once") + T.eq(Sound.playNamedEffect(data, "PORTABLE_NATIVE_FILE"), src, "native helper reuses native cache") + T.eq(calls, 1, "native helper preserves cache") + Sound.invalidate("PORTABLE_NATIVE_FILE") + love.audio = previousAudio +end +-- Actual native music one-shot and restore state; only the device is a seam. +do + local f = fixture() + f.restore() + love = require("tests.love_stub") + local previousAudio = love.audio + local src = { + playing = false, + setVolume = function() end, + setLooping = function() end, + setPitch = function() end, + stop = function(self) + self.playing = false + end, + play = function(self) + self.playing = true + end, + isPlaying = function(self) + return self.playing + end, + } + love.audio = { + newSource = function(file) + T.eq(file, "native-jingle.wav", "native music resolves file definition") + return src + end, + } + local Music = require("src.core.Music") + Music.stop() + f.game.data.audio.songs.JINGLE = { file = "native-jingle.wav" } + T.check( + f.exports.queue({ { "play_once", "JINGLE" }, { "set_flag", "mod:after" } }), + "native music one-shot admitted" + ) + T.eq(Music.current(), "JINGLE", "native music controller owns current jingle") + T.eq(Music.oneShotPlaying(), true, "native music pending restore retained") + for _ = 1, 3 do + f:tick() + end + T.eq(f.exports.get("mod:after"), false, "script waits on actual native restore state") + src.playing = false + Music.update(f.game.data) + f:drain() + T.eq(Music.oneShotPlaying(), false, "actual native controller drains restore") + T.eq(f.exports.get("mod:after"), true, "actual native restore resumes script") + Music.stop() + love.audio = previousAudio +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script audio") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index bf9cbdf38..8e6383eeb 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 8a6eca8ebcc5501bc390f39de6aeba75b2878d48 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:01:06 +0200 Subject: [PATCH 29/71] Add owned deferred dialogue audio for portable scripts --- docs/api3-portability.md | 4 +- docs/portable-world-scripts.md | 28 ++- src/mods/PortableScripts.lua | 21 +- src/mods/ScriptDialogueAudio.lua | 165 ++++++++++++++ src/mods/ScriptProgram.lua | 14 +- .../mods/script_dialogue_audio/main.lua | 15 ++ .../mods/script_dialogue_audio/manifest.json | 1 + tests/api3_contract/script_audio.lua | 5 +- tests/api3_contract/script_dialogue_audio.lua | 111 +++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_dialogue_audio.lua | 211 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 12 files changed, 571 insertions(+), 11 deletions(-) create mode 100644 src/mods/ScriptDialogueAudio.lua create mode 100644 tests/api3_contract/mods/script_dialogue_audio/main.lua create mode 100644 tests/api3_contract/mods/script_dialogue_audio/manifest.json create mode 100644 tests/api3_contract/script_dialogue_audio.lua create mode 100644 tests/engine/portable_dialogue_audio.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 4e2861f8b..443237fc8 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -134,8 +134,8 @@ The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. -Eight object/block cases and eight NPC cases extend this to 96 comparisons -and 304 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 160 comparisons +and 320 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index 6a26982f7..a8dd1a159 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -460,4 +460,30 @@ The reference Gen3 resolver can return an unknown string unchanged; this host requires a real definition before admission. Missing audio data is therefore a preflight failure; a muted/unavailable device keeps the native headless playback behavior. Already started audio remains engine-owned on -script cancellation. Dialogue-bound cries/fanfares are a separate slice. +script cancellation. Dialogue-bound cues are described below. + + +### Deferred dialogue audio + +`play_cry(canonicalSpecies, waitForButton?)`, `text_sound(id)` and +`engage_music(id)` arm the next `show_text` or `ask`, rather than playing +while its text types. Species and native cue assets are checked before any +row runs, at arm time, at box creation and again at playback. Native numeric +cry identities are resolved through canonical species names. Unknown assets +fail admission; muted devices retain native no-source behavior. + +The native TextBox waits for returned sources, with its 600-frame ceiling. +An ordinary cry auto-closes after playback; `waitForButton=true` retains the +button wait. Text sounds and engage-only music retain the button wait. +Engage music starts once, after sound completion or the native ceiling. +Questions always wait for audio before opening their native choice box. +Gen3 sequence text cues use the native fanfare music duck controller. + +Retained callbacks cannot replay cues after the box closes, beneath a foreign +overlay or after the script retires. Reentrant unload during playback aborts +the native update before it can pop an unrelated UI state. Already started +sources remain engine-owned. A combined cry and text sound plays both on this +host; the reference Gen1 opts merge drops the text sound in that combination. +Shared cases deliberately verify individual cues, not that reference defect. +They drive real native dialogue controls with synthetic font/text data and +a fake audio-device clock; they do not claim imported-ROM or PCM equivalence. diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index 9fca0ef2d..d8bb35883 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -6,6 +6,7 @@ local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") local Audio = require("src.mods.ScriptAudio") +local DialogueAudio = require("src.mods.ScriptDialogueAudio") local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") @@ -287,6 +288,10 @@ local function facade(loader, modId, native) end end end + local dialogueAudio, dialogueReason = DialogueAudio.prepare(loader, game, program) + if not dialogueAudio then + return nil, dialogueReason + end local audio, audioReason = Audio.prepare(game, program) if not audio then return nil, audioReason @@ -550,11 +555,19 @@ local function facade(loader, modId, native) ctx.lastCheck = value runner:resume() end + local opts = dialogueAudio.options(ctx, asking, function() + return not answered + and token.step == step + and check() + and box ~= nil + and contains(token.stack, box) + and token.stack:top() == box + end) box = TextBox.new(game, TextBox.fromProse(text), function() if not asking and not contains(token.stack, box) then answer(nil) end - end) + end, opts) if asking then local update, pushed, selected = box.update, false, nil box.choice = function(yes) @@ -565,7 +578,7 @@ local function facade(loader, modId, native) answer(yes) end function box:update(dt) - if self.done and (self.holdFrames or 0) <= 0 and not pushed then + if self.done and (self.holdFrames or 0) <= 0 and not pushed and not self.auto then pushed, self.choicePushed = true, true local Choice = require("src.ui.ChoiceBox") local geometry = self.choiceBox or {} @@ -1107,7 +1120,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if Audio.ARITY[verb] then + if DialogueAudio.ARITY[verb] then + dialogueAudio.arm(ctx, row) + elseif Audio.ARITY[verb] then audio(ctx, row, waitAudio, check) elseif Shops.ARITY[verb] then shop(ctx, row) diff --git a/src/mods/ScriptDialogueAudio.lua b/src/mods/ScriptDialogueAudio.lua new file mode 100644 index 000000000..ce8fde2a1 --- /dev/null +++ b/src/mods/ScriptDialogueAudio.lua @@ -0,0 +1,165 @@ +-- Deferred, owned dialogue cues. Native TextBox supplies its 600-frame gate. +local Audio = require("src.mods.ScriptAudio") +local Version = require("src.core.GameVersion") +local D = { ARITY = { play_cry = 3, text_sound = 2, engage_music = 2 } } +function D.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable dialogue audio unavailable for this generation" + end + if #row < 2 or #row > D.ARITY[verb] then + return nil, "invalid command arity" + end + if verb == "play_cry" then + if + type(row[2]) ~= "string" + or not row[2]:match("^[A-Z][A-Z0-9_]*$") + or #row[2] > 128 + or row[3] ~= nil and type(row[3]) ~= "boolean" + then + return nil, "invalid cry" + end + return { verb, row[2], row[3] } + end + local out, why = Audio.syntax( + verb == "text_sound" and "play_sound" or "play_music", + { verb == "text_sound" and "play_sound" or "play_music", row[2] }, + context + ) + if not out then + return nil, why + end + return { verb, row[2] } +end +function D.prepare(loader, game, rows) + local ids = require("src.mods.UnifiedIds").new(loader) + local function resolve(row) + if row[1] ~= "play_cry" then + return Audio.resolve(game, { row[1] == "text_sound" and "play_sound" or "play_music", row[2] }) + end + local group, why = ids:resolve("pokemon", row[2]) + local mon = group and game.data.pokemon and game.data.pokemon[group.key] + if not mon then + return nil, why or "cry species unavailable" + end + local audio = game.data.audio or {} + local def = audio.cries + and ( + audio.cries[group.key] + or type(group.key) == "number" and audio.cries[mon.translationSourceName or mon.name] + ) + local pika = Version.get() == "yellow" + and group.key == "PIKACHU" + and type(audio.pikaCries) == "table" + and audio.pikaCries[1] + if type(def) ~= "table" and type(def) ~= "string" and not pika then + return nil, "native cry unavailable: " .. row[2] + end + return { key = group.key, cry = true } + end + for i, row in ipairs(rows) do + if D.ARITY[row[1]] then + local plan, why = resolve(row) + if not plan then + return nil, "row " .. i .. ": " .. why + end + end + end + local function checked(row) + local plan, why = resolve(row) + if not plan then + error(why, 0) + end + return plan + end + local function arm(ctx, row) + checked(row) + ctx.dialogueAudio = ctx.dialogueAudio or {} + ctx.dialogueAudio[row[1]] = row + end + local function options(ctx, asking, alive) + local cues = ctx.dialogueAudio + ctx.dialogueAudio = nil + if not cues then + return nil + end + for _, row in pairs(cues) do + checked(row) + end + local Sound, Music = require("src.core.Sound"), require("src.core.Music") + local sources, started, engaged, waited = {}, false, false, 0 + -- Throw only after a callback entered alive: abort native update before it + -- can pop a foreign overlay installed by a reentrant audio hook. + local function stillAlive() + if not alive() then + error("dialogue audio owner retired", 0) + end + end + local function busy() + if not alive() then + return false + end + local playing = false + for _, src in ipairs(sources) do + local ok, value = pcall(function() + return src:isPlaying() + end) + stillAlive() + playing = playing or ok and value == true + end + return playing + end + local function sound() + if started or not alive() then + return nil + end + started = true + for _, verb in ipairs({ "play_cry", "text_sound" }) do + if cues[verb] then + local p = checked(cues[verb]) + local src + if p.cry then + src = Sound.playCry(game.data, p.key) + elseif p.sequence then + src = Sound.playNamedEffect(game.data, p.key) + else + src = Sound.play(game.data, p.key) + end + stillAlive() + if src and p.sequence then + Music.duckForFanfare(src) + stillAlive() + end + if src then + sources[#sources + 1] = src + end + end + end + return { isPlaying = busy } + end + local function tick() + if engaged or not alive() then + return + end + waited = waited + 1 + if busy() and waited < 600 then + return + end + engaged = true + if cues.engage_music then + local p = checked(cues.engage_music) + if p.stop then + Music.stop() + else + Music.play(game.data, p.key) + end + stillAlive() + end + end + local cry = cues.play_cry + return { + auto = { sound = sound, tick = tick, delay = 0, wait = asking or not cry or cry[3] == true }, + } + end + return { arm = arm, options = options } +end +return D diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 1e6b6238c..9f986e3b0 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -3,6 +3,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") local Audio = require("src.mods.ScriptAudio") +local DialogueAudio = require("src.mods.ScriptDialogueAudio") local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") @@ -48,6 +49,9 @@ end for verb, arity in pairs(Audio.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(DialogueAudio.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) if type(verb) ~= "string" then @@ -61,6 +65,7 @@ function Program.supports(verb) or Battles.ARITY[verb] or Shops.ARITY[verb] or Audio.ARITY[verb] + or DialogueAudio.ARITY[verb] ) and require("src.core.GameVersion").generation() > 3 then return false @@ -149,12 +154,19 @@ function Program.compile(raw, context) and not Battles.ARITY[verb] and not Shops.ARITY[verb] and not Audio.ARITY[verb] + and not DialogueAudio.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if Audio.ARITY[verb] then + if DialogueAudio.ARITY[verb] then + local value, reason = DialogueAudio.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Audio.ARITY[verb] then local value, reason = Audio.syntax(verb, source, context) if not value then return fail(i, reason) diff --git a/tests/api3_contract/mods/script_dialogue_audio/main.lua b/tests/api3_contract/mods/script_dialogue_audio/main.lua new file mode 100644 index 000000000..2d21c725b --- /dev/null +++ b/tests/api3_contract/mods/script_dialogue_audio/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success, reason = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok, why) + done, success, reason = done + 1, ok, why + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, reason = reason } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_dialogue_audio/manifest.json b/tests/api3_contract/mods/script_dialogue_audio/manifest.json new file mode 100644 index 000000000..d81be00df --- /dev/null +++ b/tests/api3_contract/mods/script_dialogue_audio/manifest.json @@ -0,0 +1 @@ +{"id":"script_dialogue_audio","name":"Portable dialogue audio","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_audio.lua b/tests/api3_contract/script_audio.lua index 1f957bd0f..0548e0813 100644 --- a/tests/api3_contract/script_audio.lua +++ b/tests/api3_contract/script_audio.lua @@ -1,8 +1,9 @@ -- Real loaders/adapters; an explicit source/clock seam avoids audio-device timing. local M = {} -function M.fixture(root, backend, version, modId) +function M.fixture(root, backend, version, modId, base) local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_audio%.lua$") - local f = dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, modId or "script_audio") + local f = base + or dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, modId or "script_audio") f.game.data.audio = { songs = { TUNE = { file = "tune.wav" }, JINGLE = { file = "jingle.wav" } }, sfx = { CHIME = { file = "chime.wav" } }, diff --git a/tests/api3_contract/script_dialogue_audio.lua b/tests/api3_contract/script_dialogue_audio.lua new file mode 100644 index 000000000..e0de68ec7 --- /dev/null +++ b/tests/api3_contract/script_dialogue_audio.lua @@ -0,0 +1,111 @@ +-- Actual native dialogue UI, with an explicit fake audio-device clock. +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_dialogue_audio%.lua$") + local base = + dofile(codeRoot .. "/script_gifts.lua").fixture(root, backend, version, "script_dialogue_audio") + local f = dofile(codeRoot .. "/script_audio.lua").fixture(root, backend, version, nil, base) + f.game.data.audio.cries = { MEW = { file = "mew.wav" }, [151] = { file = "mew.wav" } } + f.game.data.text.WELCOME = "WELCOME" + f.game.data.text_pointers = {} + f.ow.map.def.label = "DIALOGUE_HOME" + f.ow.game, f.ow.text = f.game, f.game.data.text + local Sound = require("src.core.Sound") + local oldCry, restore = Sound.playCry, f.restore + local function cry(_, key) + f.audioTrace[#f.audioTrace + 1] = { kind = "cry", id = "MEW" } + f.source.playing = true + return f.source + end + Sound.playCry = cry + if backend == "gen1" and f.gen == 2 then + local World = require("src.world.gen2.World") + f.ow.showText, f.ow.askYesNo = World.showText, World.askYesNo + end + local sourceRestore = function() end + if backend == "gen1" and f.gen == 3 then + local A = require("src.core.game3.audio") + local oldPlay, oldBusy = A.playCry, A.isCryFinished + A.playCry = function(key) + return cry(f.game.data, key) + end + A.isCryFinished = function() + return not f.source.playing + end + sourceRestore = function() + A.playCry, A.isCryFinished = oldPlay, oldBusy + end + end + f.restore = function() + Sound.playCry = oldCry + sourceRestore() + restore() + end + function f:tick(key) + self.game.input.wasPressed = function(_, button) + return button == key + end + self.loader.hooks:call(backend == "gen1" and "core.update" or "input.step", function() + if backend == "gen2" then + self.ow.runner:update() + end + end, self.game, 1 / 60) + if backend == "gen1" and self.gen == 3 then + local Message = require("src.ui.game3.message") + Message.tick() + if key then + if Message.isWaiting() then + Message.advance() + else + Message.pressAB() + end + end + else + self.game.stack:update(1 / 60) + end + self.game.input.wasPressed = function() + return false + end + end + return f +end +function M.run(root, backend, version) + local report = {} + for _, verb in ipairs({ "play_cry", "text_sound", "engage_music" }) do + local f = M.fixture(root, backend, version) + local cue = verb == "play_cry" and "MEW" or verb == "text_sound" and "CHIME" or "TUNE" + assert(f.exports.queue({ { verb, cue }, { "show_text", "WELCOME" }, { "set_flag", "mod:after" } })) + assert(#f.audioTrace == 0, "cue must be deferred") + for _ = 1, 300 do + f:tick() + if #f.audioTrace > 0 then + break + end + end + assert(#f.audioTrace == 1, "dialogue must start cue") + if verb ~= "engage_music" then + for _ = 1, 3 do + f:tick("a") + end + assert(f.exports.result().done == 0, "sounding cue blocks completion") + end + f.source.playing, f.musicBusy = false, false + for _ = 1, 30 do + f:tick() + end + if verb == "play_cry" then + assert(f.exports.result().success == true, "ordinary cry auto closes") + else + assert(f.exports.result().done == 0, "text cue keeps button wait") + for _ = 1, 30 do + f:tick("a") + end + end + assert(f.exports.result().success == true and f.exports.get("mod:after")) + report[verb] = { deferred = true, complete = true } + f.restore() + end + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index f4e4578e1..2dfe31b80 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -26,11 +26,14 @@ assert( or mode == "script_trainers" or mode == "script_shops" or mode == "script_audio" + or mode == "script_dialogue_audio" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_audio" then +if mode == "script_dialogue_audio" then + dofile(codeRoot .. "/script_dialogue_audio.lua").run(root, backend, version) +elseif mode == "script_audio" then dofile(codeRoot .. "/script_audio.lua").run(root, backend, version) elseif mode == "script_shops" then dofile(codeRoot .. "/script_shops.lua").run(root, backend, version) diff --git a/tests/engine/portable_dialogue_audio.lua b/tests/engine/portable_dialogue_audio.lua new file mode 100644 index 000000000..a5882405f --- /dev/null +++ b/tests/engine/portable_dialogue_audio.lua @@ -0,0 +1,211 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_dialogue_audio") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +local function sounding(f) + for _ = 1, 300 do + f:tick() + if #f.audioTrace > 0 then + return f.game.stack:top() + end + end + error("no deferred cue") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " native dialogue audio contract: " .. tostring(why)) +end +for _, row in ipairs({ + { "play_cry", "UNKNOWN" }, + { "play_cry", 151 }, + { "play_cry", "MEW", 1 }, + { "play_cry", "MEW", true, 1 }, + { "text_sound", "UNKNOWN" }, + { "engage_music", "UNKNOWN" }, + { "text_sound", "CHIME", false }, + { "engage_music", false }, +}) do + local f = fixture() + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, row }), nil, "bad deferred cue refused") + T.eq(f.exports.get("mod:prefix"), false, "whole script preflight") + T.eq(#f.audioTrace, 0, "preflight is silent") + f.restore() +end +for _, change in ipairs({ "rollback", "save", "data", "map" }) do + for _, phase in ipairs({ "typing", "sounding", "button" }) do + local f = fixture() + T.check( + f.exports.queue({ + { "play_cry", "MEW", true }, + { "engage_music", "TUNE" }, + { "show_text", "WELCOME" }, + { "set_flag", "mod:after" }, + }), + "retirement fixture queued" + ) + local box = f.game.stack:top() + local auto = box.auto + if phase ~= "typing" then + sounding(f) + end + if phase == "button" then + f.source.playing = false + f:tick() + end + if change == "rollback" then + f.loader:_rollback("script_dialogue_audio") + elseif change == "map" then + f.ow.map = { id = f.home } + else + f.game[change] = {} + end + local foreign = { update = function() end } + f.game.stack:push(foreign) + -- Retained callbacks after retirement cannot replay cues or remove UI. + auto.sound() + auto.tick() + f:tick() + T.eq(f.exports.get("mod:after"), false, "retired cue has no continuation") + T.eq(f.game.stack:top(), foreign, "foreign overlay retained") + T.eq(f.exports.result().done, 1, "retired cue completes once") + f.restore() + end +end +for _, callback in ipairs({ "sound", "poll", "engage" }) do + local f = fixture() + local Sound, Music = require("src.core.Sound"), require("src.core.Music") + local foreign = { update = function() end } + local function retire() + f.loader:_rollback("script_dialogue_audio") + f.game.stack:push(foreign) + end + local oldSound, oldMusic = Sound.playCry, Music.play + if callback == "sound" then + Sound.playCry = function(...) + local src = oldSound(...) + retire() + return src + end + elseif callback == "poll" then + f.source.isPlaying = function() + retire() + return false + end + else + Music.play = function(...) + oldMusic(...) + retire() + end + end + T.check( + f.exports.queue({ + { "play_cry", "MEW" }, + { "engage_music", "TUNE" }, + { "show_text", "WELCOME" }, + { "set_flag", "mod:after" }, + }), + "reentrant cue admitted" + ) + for _ = 1, 300 do + f:tick() + if callback == "engage" then + f.source.playing = false + end + if f.exports.result().done > 0 then + break + end + end + T.eq(f.game.stack:top(), foreign, "reentrant callback cannot pop foreign state") + T.eq(f.exports.result().done, 1, "reentrant completion once") + T.eq(f.exports.get("mod:after"), false, "reentrant continuation suppressed") + Sound.playCry, Music.play = oldSound, oldMusic + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ + { "play_cry", "MEW" }, + { "text_sound", "CHIME" }, + { "engage_music", "TUNE" }, + { "ask", "WELCOME" }, + { "set_flag", "mod:after" }, + }), + "combined cues with question" + ) + local box = sounding(f) + T.eq(#f.audioTrace, 2, "both cry and text effect play") + for _ = 1, 3 do + f:tick("a") + end + T.eq(f.game.stack:top(), box, "question waits for sound before choice") + local retained = box.auto + retained.sound() + T.eq(#f.audioTrace, 2, "sound callback is once only") + f.source.playing = false + for _ = 1, 3 do + f:tick() + end + T.check(f.game.stack:top() ~= box, "native choice after audio gate") + T.eq(f.audioTrace[3].kind, "music", "engage follows completed cue") + retained.tick() + retained.sound() + T.eq(#f.audioTrace, 3, "underlay callbacks add no playback") + for _ = 1, 30 do + f:tick("a") + end + T.eq(f.exports.result().success, true, "choice continues script once") + T.eq(f.exports.result().done, 1, "choice single completion") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ + { "play_cry", "MEW" }, + { "engage_music", "TUNE" }, + { "show_text", "WELCOME" }, + { "set_flag", "mod:after" }, + }), + "bounded cry fixture" + ) + sounding(f) + for _ = 1, 600 do + f:tick() + end + T.eq(f.exports.result().success, true, "600-frame native audio ceiling releases dialogue") + T.eq(#f.audioTrace, 2, "music starts once at sound ceiling") + f.restore() +end +for _, stage in ipairs({ "dispatch", "typed" }) do + local f = fixture() + T.check( + f.exports.queue({ { "wait", 2 }, { "play_cry", "MEW" }, { "show_text", "WELCOME" }, { + "set_flag", + "mod:after", + } }), + "asset-change fixture" + ) + if stage == "typed" then + for _ = 1, 4 do + f:tick() + end + end + f.game.data.audio.cries.MEW = nil + for _ = 1, 300 do + f:tick() + end + T.eq(f.exports.result().success, false, "missing cry revalidated at " .. stage) + T.eq(f.exports.get("mod:after"), false, "changed asset prevents continuation") + T.eq(#f.audioTrace, 0, "missing asset never played") + f.restore() +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5] +love = saved[6] +T.finish() diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 8e6383eeb..424fb4e14 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio", "script_dialogue_audio"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 5528ea28a9e8f9451aa27bbc6f2d411fa8ef2e62 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:05:35 +0200 Subject: [PATCH 30/71] Add bounded portable save-field and option commands --- docs/api3-portability.md | 4 +- docs/portable-world-scripts.md | 25 +++ src/mods/PortableScripts.lua | 9 +- src/mods/ScriptFields.lua | 127 +++++++++++++++ src/mods/ScriptProgram.lua | 14 +- .../api3_contract/mods/script_fields/main.lua | 15 ++ .../mods/script_fields/manifest.json | 1 + tests/api3_contract/script_fields.lua | 41 +++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_fields.lua | 146 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 11 files changed, 383 insertions(+), 6 deletions(-) create mode 100644 src/mods/ScriptFields.lua create mode 100644 tests/api3_contract/mods/script_fields/main.lua create mode 100644 tests/api3_contract/mods/script_fields/manifest.json create mode 100644 tests/api3_contract/script_fields.lua create mode 100644 tests/engine/portable_script_fields.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 443237fc8..92b941d4c 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -134,8 +134,8 @@ The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. -Eight object/block cases and eight NPC cases extend this to 160 comparisons -and 320 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 168 comparisons +and 336 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index a8dd1a159..ad8e876c9 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -487,3 +487,28 @@ host; the reference Gen1 opts merge drops the text sound in that combination. Shared cases deliberately verify individual cues, not that reference defect. They drive real native dialogue controls with synthetic font/text data and a fake audio-device clock; they do not claim imported-ROM or PCM equivalence. + +### Save fields and options + +`set_field(key, value?)` writes a native save-root field; `mod:key` writes +only the current mod's `save.modData[modId][key]`. `set_option(key, value?)` +writes the native `save.options[key]` dictionary. As in the reference, +`set_option("mod:key", value)` retains that literal option key; it does not +redirect to a private bucket. Omitting the value deletes the selected entry. +Native root field and option names/meanings remain game-specific. + +Values must be finite numbers, strings, booleans or acyclic plain data tables +with string/positive-integer keys. Functions, metatables and foreign mod +names (`mod.other.key`, `other:key`) are refused. Replacing the root `modData` +store is refused so `set_field` cannot bypass ownership. This is intentionally +stricter than the reference's unrestricted root write. A value is limited to +16 nested levels, 1,024 nodes and 32 KiB of strings; the complete program is +limited to 8,192 nodes and 128 KiB of field strings. + +The compiler snapshots values; each execution copies again so neither the +caller nor an earlier loop iteration can mutate compiled data. All target +store shapes are checked before admission and again at dispatch. A linear +preflight accounts for an earlier root `options` replacement before a later +`set_option`. Conditional/loop behavior can still change native store validity +at runtime; a failed runtime check stops further rows. Root writes do not +translate game-specific semantics or persist the save file automatically. diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index d8bb35883..72d855cec 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -6,6 +6,7 @@ local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") local Audio = require("src.mods.ScriptAudio") +local Fields = require("src.mods.ScriptFields") local DialogueAudio = require("src.mods.ScriptDialogueAudio") local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") @@ -288,6 +289,10 @@ local function facade(loader, modId, native) end end end + local fields, fieldsReason = Fields.prepare(game, modId, program) + if not fields then + return nil, fieldsReason + end local dialogueAudio, dialogueReason = DialogueAudio.prepare(loader, game, program) if not dialogueAudio then return nil, dialogueReason @@ -1120,7 +1125,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if DialogueAudio.ARITY[verb] then + if Fields.ARITY[verb] then + fields(ctx, row) + elseif DialogueAudio.ARITY[verb] then dialogueAudio.arm(ctx, row) elseif Audio.ARITY[verb] then audio(ctx, row, waitAudio, check) diff --git a/src/mods/ScriptFields.lua b/src/mods/ScriptFields.lua new file mode 100644 index 000000000..ade27470a --- /dev/null +++ b/src/mods/ScriptFields.lua @@ -0,0 +1,127 @@ +-- Data-only field writes; no executable values or namespace escape. +local F = { ARITY = { set_field = 3, set_option = 3 } } +local function copy(value, state, depth) + state.nodes = state.nodes + 1 + if state.nodes > 1024 or depth > 16 then + error("script field data limit", 0) + end + local kind = type(value) + if kind == "nil" or kind == "boolean" then + return value + end + if kind == "number" then + if value ~= value or value == math.huge or value == -math.huge then + error("invalid field number", 0) + end + return value + end + if kind == "string" then + state.bytes = state.bytes + #value + if state.bytes > 32768 then + error("script field string limit", 0) + end + return value + end + if kind ~= "table" or getmetatable(value) ~= nil or state.seen[value] then + error("script fields require acyclic plain data", 0) + end + state.seen[value] = true + local out = {} + for k, v in pairs(value) do + if type(k) ~= "string" and not (type(k) == "number" and k >= 1 and k % 1 == 0 and k < math.huge) then + error("invalid field data key", 0) + end + local key = copy(k, state, depth + 1) + out[key] = copy(v, state, depth + 1) + end + state.seen[value] = nil + return out +end +local function snapshot(value) + local state = { nodes = 0, bytes = 0, seen = {} } + return copy(value, state, 0), state +end +function F.syntax(verb, row, context) + if (context.generation or require("src.core.GameVersion").generation()) > 3 then + return nil, "portable fields unavailable for this generation" + end + local key = row[2] + if + #row < 2 + or #row > 3 + or type(key) ~= "string" + or #key == 0 + or #key > 256 + or key:find("%c") + or key:match("^mod%.") + or key == "mod:" + or key:find(":", 1, true) and not key:match("^mod:.+") + or verb == "set_field" and key == "modData" + then + return nil, "invalid or foreign script field" + end + local ok, value = pcall(snapshot, row[3]) + if not ok then + return nil, value + end + return { verb, key, value } +end +local function plain(value) + return value == nil or type(value) == "table" and getmetatable(value) == nil +end +function F.prepare(game, owner, rows) + local bytes, nodes = 0, 0 + local options = game.save.options + local function stores(row, plannedOptions) + if row[1] == "set_option" then + if not plain(plannedOptions) then + return nil, "invalid options store" + end + elseif row[2]:match("^mod:") then + local data = game.save.modData + if not plain(data) or data and not plain(data[owner]) then + return nil, "invalid mod save store" + end + end + return true + end + for i, row in ipairs(rows) do + if F.ARITY[row[1]] then + local _, size = snapshot(row[3]) + bytes, nodes = bytes + size.bytes, nodes + size.nodes + if bytes > 131072 or nodes > 8192 then + return nil, "aggregate script field data limit" + end + local ok, why = stores(row, options) + if not ok then + return nil, "row " .. i .. ": " .. why + end + if row[1] == "set_field" and row[2] == "options" then + options = row[3] + end + end + end + return function(_, row) + local ok, why = stores(row, game.save.options) + if not ok then + error(why, 0) + end + -- Copy again on each dispatch: loops and post-run save mutations never + -- expose or mutate the compiled program's snapshot. + local value = snapshot(row[3]) + if row[1] == "set_option" then + game.save.options = game.save.options or {} + game.save.options[row[2]] = value + else + local private = row[2]:match("^mod:(.+)$") + if private then + game.save.modData = game.save.modData or {} + game.save.modData[owner] = game.save.modData[owner] or {} + game.save.modData[owner][private] = value + else + game.save[row[2]] = value + end + end + end +end +return F diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 9f986e3b0..0bcc7fdaf 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -3,6 +3,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") local Audio = require("src.mods.ScriptAudio") +local Fields = require("src.mods.ScriptFields") local DialogueAudio = require("src.mods.ScriptDialogueAudio") local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") @@ -52,6 +53,9 @@ end for verb, arity in pairs(DialogueAudio.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(Fields.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) if type(verb) ~= "string" then @@ -66,6 +70,7 @@ function Program.supports(verb) or Shops.ARITY[verb] or Audio.ARITY[verb] or DialogueAudio.ARITY[verb] + or Fields.ARITY[verb] ) and require("src.core.GameVersion").generation() > 3 then return false @@ -155,12 +160,19 @@ function Program.compile(raw, context) and not Shops.ARITY[verb] and not Audio.ARITY[verb] and not DialogueAudio.ARITY[verb] + and not Fields.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if DialogueAudio.ARITY[verb] then + if Fields.ARITY[verb] then + local value, reason = Fields.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif DialogueAudio.ARITY[verb] then local value, reason = DialogueAudio.syntax(verb, source, context) if not value then return fail(i, reason) diff --git a/tests/api3_contract/mods/script_fields/main.lua b/tests/api3_contract/mods/script_fields/main.lua new file mode 100644 index 000000000..2d21c725b --- /dev/null +++ b/tests/api3_contract/mods/script_fields/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success, reason = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok, why) + done, success, reason = done + 1, ok, why + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, reason = reason } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_fields/manifest.json b/tests/api3_contract/mods/script_fields/manifest.json new file mode 100644 index 000000000..c50380150 --- /dev/null +++ b/tests/api3_contract/mods/script_fields/manifest.json @@ -0,0 +1 @@ +{"id":"script_fields","name":"Portable script fields","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_fields.lua b/tests/api3_contract/script_fields.lua new file mode 100644 index 000000000..8b4f1a93f --- /dev/null +++ b/tests/api3_contract/script_fields.lua @@ -0,0 +1,41 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_fields%.lua$") + return dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, "script_fields") +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + f.game.save.modData.foreign = { marker = 17 } + local value = { phase = 2, enabled = false, items = { "A", 3 } } + assert( + f.exports.queue({ + { "wait", 1 }, + { "set_field", "portableRoot", true }, + { "set_field", "mod:story", value }, + { "set_option", "portableChoice", "FAST" }, + { "set_option", "mod:theme", false }, + { "set_flag", "mod:after" }, + }) + ) + value.phase = 9 + value.items[1] = "CHANGED" + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + local save = f.game.save + assert(save.portableRoot == true and save.modData.script_fields.story.phase == 2) + assert(save.modData.script_fields.story.items[1] == "A" and save.modData.foreign.marker == 17) + local report = { + root = save.portableRoot, + private = save.modData.script_fields.story, + options = save.options, + foreign = 17, + } + f = M.fixture(root, backend, version) + local result, why = + f.exports.queue({ { "set_field", "portableRoot", true }, { "set_field", "mod.foreign.field", 1 } }) + assert(result == nil and type(why) == "string" and f.game.save.portableRoot == nil) + report.preflight = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 2dfe31b80..84ff30889 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -27,11 +27,14 @@ assert( or mode == "script_shops" or mode == "script_audio" or mode == "script_dialogue_audio" + or mode == "script_fields" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_dialogue_audio" then +if mode == "script_fields" then + dofile(codeRoot .. "/script_fields.lua").run(root, backend, version) +elseif mode == "script_dialogue_audio" then dofile(codeRoot .. "/script_dialogue_audio.lua").run(root, backend, version) elseif mode == "script_audio" then dofile(codeRoot .. "/script_audio.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_fields.lua b/tests/engine/portable_script_fields.lua new file mode 100644 index 000000000..81f85b718 --- /dev/null +++ b/tests/engine/portable_script_fields.lua @@ -0,0 +1,146 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_fields") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared field contract: " .. tostring(why)) +end +local cyclic = {} +cyclic.self = cyclic +local deep = {} +local node = deep +for _ = 1, 18 do + node.child = {} + node = node.child +end +local wide = {} +for i = 1, 600 do + wide[i] = i +end +for _, row in ipairs({ + { "set_field", "modData", {} }, + { "set_field", "mod:" }, + { "set_field", "mod.other.x", 1 }, + { "set_field", "foreign:x", 1 }, + { "set_option", "x\n", 1 }, + { "set_field", "", 1 }, + { "set_field", false, 1 }, + { "set_field", "x", function() end }, + { "set_field", "x", 0 / 0 }, + { "set_field", "x", math.huge }, + { "set_field", "x", cyclic }, + { "set_field", "x", deep }, + { "set_field", "x", wide }, + { "set_field", "x", string.rep("a", 32769) }, + { "set_field", "x", { [false] = 1 } }, + { "set_field", "x", setmetatable({}, {}) }, + { "set_option", "x", { nested = function() end } }, + { "set_option", "x", 1, 2 }, +}) do + local f = fixture() + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, row }), nil, "invalid field rejected") + T.eq(f.exports.get("mod:prefix"), false, "invalid field prevents prefix") +end +for _, store in ipairs({ "options", "modData", "bucket" }) do + for _, value in ipairs({ false, setmetatable({}, {}) }) do + local f = fixture() + local verb, key = "set_option", "choice" + if store == "bucket" then + f.game.save.modData.script_fields = value + verb, key = "set_field", "mod:x" + else + f.game.save[store] = value + if store == "modData" then + verb, key = "set_field", "mod:x" + end + end + T.eq( + f.exports.queue({ { "set_field", "portableRoot", true }, { verb, key, 1 } }), + nil, + "invalid store refused" + ) + T.eq(f.game.save.portableRoot, nil, "store failure before prefix") + end +end +do + local f = fixture() + T.check( + f.exports.queue({ { "wait", 1 }, { "set_field", "mod:x", 1 }, { "set_flag", "mod:after" } }), + "changed bucket fixture" + ) + f.game.save.modData.script_fields = false + f:drain() + T.eq(f.exports.result().success, false, "store revalidated at dispatch") + T.eq(f.game.save.modData.script_fields, false, "changed bucket retained") + T.eq(f.exports.get("mod:after"), nil, "changed bucket has no continuation") +end +do + local f = fixture() + T.eq( + f.exports.queue({ { "set_field", "options", false }, { "set_option", "x", 1 } }), + nil, + "future options shape preflight" + ) + T.eq(f.game.save.options, nil, "future invalid store leaves original untouched") +end +do + local f = fixture() + local value = { a = { 3, false, "x" } } + T.check( + f.exports.queue({ + { "set_field", "mod:a", value }, + { "set_field", "portableRoot", 9 }, + { "set_option", "portableChoice", true }, + }), + "values written" + ) + f:drain() + T.eq(f.game.save.modData.script_fields.a.a[2], false, "false preserved") + value.a[1] = 7 + T.eq(f.game.save.modData.script_fields.a.a[1], 3, "caller table not aliased") + T.check( + f.exports.queue({ { "set_field", "mod:a" }, { "set_field", "portableRoot" }, { + "set_option", + "portableChoice", + } }), + "omitted value deletes" + ) + f:drain() + T.eq(f.game.save.modData.script_fields.a, nil, "private field cleared") + T.eq(f.game.save.portableRoot, nil, "root field cleared") + T.eq(f.game.save.options.portableChoice, nil, "option cleared") +end +do + local F = require("src.mods.ScriptFields") + local game = { save = {} } + local rows = { { "set_field", "mod:x", { n = 1 } } } + local run = assert(F.prepare(game, "owner", rows)) + run({}, rows[1]) + game.save.modData.owner.x.n = 4 + run({}, rows[1]) + T.eq(game.save.modData.owner.x.n, 1, "repeated dispatch copies compiled value") + local rows = {} + for i = 1, 5 do + rows[i] = { "set_field", "mod:x", string.rep("a", 32768) } + end + T.eq(F.prepare(game, "owner", rows), nil, "aggregate data string limit") + local rows = {} + for i = 1, 20 do + local v = {} + for j = 1, 250 do + v[j] = j + end + rows[i] = { "set_field", "mod:x", v } + end + T.eq(F.prepare(game, "owner", rows), nil, "aggregate data node limit") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5] +love = saved[6] +T.finish() diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 424fb4e14..66b2755d9 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio", "script_dialogue_audio"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio", "script_dialogue_audio", "script_fields"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 1d9b8ef0ae11e8faa791da895d9b3d305da1be4e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:19:29 +0200 Subject: [PATCH 31/71] Map portable move effects to native Gen1-3 handlers --- docs/api3-portability.md | 4 +- docs/unified-mod-content.md | 35 +- src/battle/MoveEffects.lua | 2 +- src/mods/MoveEffectContract.lua | 1213 +++++++++++++++++ src/mods/UnifiedContent.lua | 29 +- src/mods/UnifiedEffects.lua | 135 ++ src/mods/UnifiedSchemas.lua | 2 +- tests/api3_contract/effects.lua | 13 + tests/api3_contract/mods/effects/main.lua | 33 + .../api3_contract/mods/effects/manifest.json | 1 + tests/api3_contract/worker.lua | 5 +- tests/engine/unified_move_effects.lua | 208 +++ tools/api3-contract-check/check.py | 2 +- .../move-effect-contract/export_reference.lua | 44 + tools/move-effect-contract/generate.py | 75 + 15 files changed, 1786 insertions(+), 15 deletions(-) create mode 100644 src/mods/MoveEffectContract.lua create mode 100644 src/mods/UnifiedEffects.lua create mode 100644 tests/api3_contract/effects.lua create mode 100644 tests/api3_contract/mods/effects/main.lua create mode 100644 tests/api3_contract/mods/effects/manifest.json create mode 100644 tests/engine/unified_move_effects.lua create mode 100644 tools/move-effect-contract/export_reference.lua create mode 100644 tools/move-effect-contract/generate.py diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 92b941d4c..d59ecfc36 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -134,8 +134,8 @@ The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. -Eight object/block cases and eight NPC cases extend this to 168 comparisons -and 336 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 176 comparisons +and 352 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge diff --git a/docs/unified-mod-content.md b/docs/unified-mod-content.md index 64d83da74..6108608a9 100644 --- a/docs/unified-mod-content.md +++ b/docs/unified-mod-content.md @@ -44,7 +44,7 @@ format's availability; other capability surfaces retain their existing answers. | Handle | Unified fields | Supported versions | | --- | --- | --- | | `pokemon` | `id`, `name`, `dex`, `types`, `baseStats`, `catchRate`, `baseExp`, `growthRate`, `level1Moves`, `learnset`, `evolutions`, `tmhm`; optional breeding/art fields | Existing definitions, Gen 1–5 | -| `moves` | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `effect`, `category`, `priority`, `description` | Existing definitions, Gen 1–5 | +| `moves` | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `effect`, `secondaryChance`, `category`, `priority`, `description` | Existing definitions, Gen 1–5 | | `items` | `id`, `name`, `price`, `machine`, `effect`, `ball`, `tossable`, `needsTarget`, `description` | Existing definitions, Gen 1–5 | | `trainers` | `id`, `name`, `pic`, `baseMoney`, `battleTheme`, `partyNames`, `parties` | Existing definitions, Gen 1–5 | | `encounters` | `id`, `grass`, `water` | Gen 1–3 grass/water tables | @@ -114,11 +114,34 @@ per-slot IVs, flags and AI metadata are retained. A DS trainer's single `party` and `parties[1]` aliases stay synchronized. Move accuracy is percent, as in the engine's existing normalized definitions. -Native effect selectors and item behavior remain opaque: changing a move's -effect on Gen 2–5, changing later item behavior, or changing a DS move type -without a native type adapter returns `nil, reason`. This avoids changing a -symbolic field while leaving an incompatible native selector behind. Power, -accuracy, PP, category, price and other represented fields can be edited. +Gen1–3 move effects use canonical IDs, resolved to the host's actual native +battle handlers before an operation is journaled. For example +`SPECIAL_UP1_EFFECT` selects `SP_ATK_UP1_EFFECT` on Gen2/3; the Gen1 Special +stage does not leak into split Special Attack/Defense. `HIT` and `SLEEP` are +accepted aliases. Registered custom native effects retain their own IDs. + +`secondaryChance` is an optional whole percentage (0–100) on Gen2/3. Explicit +odds convert to the nearest native fraction out of 256; detached unmodified +records retain their exact original byte. Explicit zero is distinguished from +a legacy native zero byte whose controller uses a fallback. Changing a +canonical effect applies its reference default chance unless the caller +supplies odds. Authored aliases survive later odds edits. + +Unknown native selectors are opaque `native:generation:selector` tokens on +Gen2/3. They roundtrip only with their original record; they cannot assign an +unknown handler to another move. Gen4/5 retain the previous diagnostic-only +numeric selector behavior and refuse effect/chance changes. Item behavior +and changing a DS move type remain unavailable without their native adapters. + +The data-only binding catalog is reproduced from pinned Gen1Recomp contract +maps plus the host importer's native names by +`tools/move-effect-contract/generate.py --gen1 CLEAN_REFERENCE --lua LUAJIT +--check`. It does not execute imported content. A binding still requires a +real registered host handler: FOLLOW_ME, HELPING_HAND, MAGIC_COAT, SNATCH, +IMPRISON, ASSIST, NATURE_POWER and SECRET_POWER are refused on the current +host. Identity-dependent native Rest/SonicBoom variants are not assigned to +an incompatible move. This adapter selects native behavior; it does not add +missing battle mechanics or promise cross-engine RNG/pixel equivalence. ### Encounters diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index ed4249843..fa959fcef 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -144,7 +144,7 @@ end -- SHIELD DUST still refuses a doubled roll. local function sideChance(move, fallback, user, target) local own = move and tonumber(move.effectChance) - local base = (own and own > 0) and own or fallback + local base = (own and (own > 0 or move._api3EffectChance)) and own or fallback if user or target then base = require("src.battle.Abilities").secondaryOdds(user, target, base) end diff --git a/src/mods/MoveEffectContract.lua b/src/mods/MoveEffectContract.lua new file mode 100644 index 000000000..1d1ec1c27 --- /dev/null +++ b/src/mods/MoveEffectContract.lua @@ -0,0 +1,1213 @@ +-- Generated by tools/move-effect-contract/generate.py; do not hand-edit. +return { + ["bindings"] = { + ["ACCURACY_DOWN1_EFFECT"] = {["1"]={["native"]="ACCURACY_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ACCURACY_DOWN",["semantic"]=23},["3"]={["chance"]=0,["native"]=23,["semantic"]=23}}, + ["ASSIST"] = {["3"]={["native"]=180,["semantic"]=180}}, + ["ATTACK_DOWN1_EFFECT"] = {["1"]={["native"]="ATTACK_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_DOWN",["semantic"]=18},["3"]={["chance"]=0,["native"]=18,["semantic"]=18}}, + ["ATTACK_DOWN2_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_DOWN_2",["semantic"]=58},["3"]={["chance"]=0,["native"]=58,["semantic"]=58}}, + ["ATTACK_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="ATTACK_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_ATTACK_DOWN_HIT",["semantic"]=68},["3"]={["chance"]=33,["native"]=68,["semantic"]=68}}, + ["ATTACK_TWICE_EFFECT"] = {["1"]={["native"]="ATTACK_TWICE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DOUBLE_HIT",["semantic"]=44},["3"]={["chance"]=0,["native"]=44,["semantic"]=44}}, + ["ATTACK_UP1_EFFECT"] = {["1"]={["native"]="ATTACK_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_UP",["semantic"]=10},["3"]={["chance"]=0,["native"]=10,["semantic"]=10}}, + ["ATTACK_UP2_EFFECT"] = {["1"]={["native"]="ATTACK_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_UP_2",["semantic"]=50},["3"]={["chance"]=0,["native"]=50,["semantic"]=50}}, + ["BIDE_EFFECT"] = {["1"]={["native"]="BIDE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_BIDE",["semantic"]=26},["3"]={["chance"]=0,["native"]=26,["semantic"]=26}}, + ["BLAZE_KICK"] = {["3"]={["native"]=200,["semantic"]=200}}, + ["BRICK_BREAK"] = {["3"]={["native"]=186,["semantic"]=186}}, + ["BULK_UP"] = {["3"]={["native"]=208,["semantic"]=208}}, + ["BURN_SIDE_EFFECT1"] = {["1"]={["native"]="BURN_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_BURN_HIT",["semantic"]=4},["3"]={["chance"]=10,["native"]=4,["semantic"]=4}}, + ["BURN_SIDE_EFFECT2"] = {["1"]={["native"]="BURN_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_BURN_HIT",["semantic"]=4},["3"]={["chance"]=30,["native"]=4,["semantic"]=4}}, + ["CALM_MIND"] = {["3"]={["native"]=211,["semantic"]=211}}, + ["CAMOUFLAGE"] = {["3"]={["native"]=213,["semantic"]=213}}, + ["CHARGE"] = {["3"]={["native"]=174,["semantic"]=174}}, + ["CHARGE_EFFECT"] = {["1"]={["native"]="CHARGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAZOR_WIND",["semantic"]=39},["3"]={["chance"]=0,["native"]=39,["semantic"]=39}}, + ["CONFUSION_EFFECT"] = {["1"]={["native"]="CONFUSION_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_CONFUSE",["semantic"]=49},["3"]={["chance"]=0,["native"]=49,["semantic"]=49}}, + ["CONFUSION_SIDE_EFFECT"] = {["1"]={["native"]="CONFUSION_SIDE_EFFECT"},["2"]={["chance"]=10,["native"]="EFFECT_CONFUSE_HIT",["semantic"]=76},["3"]={["chance"]=10,["native"]=76,["semantic"]=76}}, + ["CONVERSION_2"] = {["3"]={["native"]=93,["semantic"]=93}}, + ["CONVERSION_EFFECT"] = {["1"]={["native"]="CONVERSION_EFFECT"},["3"]={["chance"]=0,["native"]=30,["semantic"]=30}}, + ["COSMIC_POWER"] = {["3"]={["native"]=206,["semantic"]=206}}, + ["DEFENSE_DOWN1_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_DOWN",["semantic"]=19},["3"]={["chance"]=0,["native"]=19,["semantic"]=19}}, + ["DEFENSE_DOWN2_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_DOWN_2",["semantic"]=59},["3"]={["chance"]=0,["native"]=59,["semantic"]=59}}, + ["DEFENSE_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_DEFENSE_DOWN_HIT",["semantic"]=69},["3"]={["chance"]=33,["native"]=69,["semantic"]=69}}, + ["DEFENSE_UP1_EFFECT"] = {["1"]={["native"]="DEFENSE_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_UP",["semantic"]=11},["3"]={["chance"]=0,["native"]=11,["semantic"]=11}}, + ["DEFENSE_UP2_EFFECT"] = {["1"]={["native"]="DEFENSE_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_UP_2",["semantic"]=51},["3"]={["chance"]=0,["native"]=51,["semantic"]=51}}, + ["DESTINY_BOND"] = {["3"]={["native"]=98,["semantic"]=98}}, + ["DISABLE_EFFECT"] = {["1"]={["native"]="DISABLE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DISABLE",["semantic"]=86},["3"]={["chance"]=0,["native"]=86,["semantic"]=86}}, + ["DOUBLE_EDGE"] = {["3"]={["native"]=198,["semantic"]=198}}, + ["DRAGON_DANCE"] = {["3"]={["native"]=212,["semantic"]=212}}, + ["DRAIN_HP_EFFECT"] = {["1"]={["native"]="DRAIN_HP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEECH_HIT",["semantic"]=3},["3"]={["chance"]=0,["native"]=3,["semantic"]=3}}, + ["DREAM_EATER_EFFECT"] = {["1"]={["native"]="DREAM_EATER_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DREAM_EATER",["semantic"]=8},["3"]={["chance"]=0,["native"]=8,["semantic"]=8}}, + ["EARTHQUAKE"] = {["3"]={["native"]=147,["semantic"]=147}}, + ["EFFECT_ACCURACY_DOWN_HIT"] = {["2"]={["native"]="EFFECT_ACCURACY_DOWN_HIT",["semantic"]=73},["3"]={["native"]=73,["semantic"]=73}}, + ["EFFECT_ALL_UP_HIT"] = {["2"]={["native"]="EFFECT_ALL_UP_HIT",["semantic"]=140},["3"]={["native"]=140,["semantic"]=140}}, + ["EFFECT_ATTACK_UP_HIT"] = {["2"]={["native"]="EFFECT_ATTACK_UP_HIT",["semantic"]=139},["3"]={["native"]=139,["semantic"]=139}}, + ["EFFECT_ATTRACT"] = {["2"]={["native"]="EFFECT_ATTRACT",["semantic"]=120},["3"]={["native"]=120,["semantic"]=120}}, + ["EFFECT_BATON_PASS"] = {["2"]={["native"]="EFFECT_BATON_PASS",["semantic"]=127},["3"]={["native"]=127,["semantic"]=127}}, + ["EFFECT_BEAT_UP"] = {["2"]={["native"]="EFFECT_BEAT_UP",["semantic"]=154},["3"]={["native"]=154,["semantic"]=154}}, + ["EFFECT_BELLY_DRUM"] = {["2"]={["native"]="EFFECT_BELLY_DRUM",["semantic"]=142},["3"]={["native"]=142,["semantic"]=142}}, + ["EFFECT_CONVERSION2"] = {["3"]={["native"]=93,["semantic"]=93}}, + ["EFFECT_CURSE"] = {["2"]={["native"]="EFFECT_CURSE",["semantic"]=109},["3"]={["native"]=109,["semantic"]=109}}, + ["EFFECT_DEFENSE_CURL"] = {["2"]={["native"]="EFFECT_DEFENSE_CURL",["semantic"]=156},["3"]={["native"]=156,["semantic"]=156}}, + ["EFFECT_DEFENSE_UP_HIT"] = {["2"]={["native"]="EFFECT_DEFENSE_UP_HIT",["semantic"]=138},["3"]={["native"]=138,["semantic"]=138}}, + ["EFFECT_DESTINY_BOND"] = {["3"]={["native"]=98,["semantic"]=98}}, + ["EFFECT_EARTHQUAKE"] = {["3"]={["native"]=147,["semantic"]=147}}, + ["EFFECT_ENCORE"] = {["2"]={["native"]="EFFECT_ENCORE",["semantic"]=90},["3"]={["native"]=90,["semantic"]=90}}, + ["EFFECT_ENDURE"] = {["2"]={["native"]="EFFECT_ENDURE",["semantic"]=116},["3"]={["native"]=116,["semantic"]=116}}, + ["EFFECT_FAKE_OUT"] = {["3"]={["native"]=158,["semantic"]=158}}, + ["EFFECT_FALSE_SWIPE"] = {["2"]={["native"]="EFFECT_FALSE_SWIPE",["semantic"]=101},["3"]={["native"]=101,["semantic"]=101}}, + ["EFFECT_FLAME_WHEEL"] = {["3"]={["native"]=125,["semantic"]=125}}, + ["EFFECT_FORESIGHT"] = {["2"]={["native"]="EFFECT_FORESIGHT",["semantic"]=113},["3"]={["native"]=113,["semantic"]=113}}, + ["EFFECT_FRUSTRATION"] = {["2"]={["native"]="EFFECT_FRUSTRATION",["semantic"]=123},["3"]={["native"]=123,["semantic"]=123}}, + ["EFFECT_FURY_CUTTER"] = {["2"]={["native"]="EFFECT_FURY_CUTTER",["semantic"]=119},["3"]={["native"]=119,["semantic"]=119}}, + ["EFFECT_FUTURE_SIGHT"] = {["2"]={["native"]="EFFECT_FUTURE_SIGHT",["semantic"]=148},["3"]={["native"]=148,["semantic"]=148}}, + ["EFFECT_GUST"] = {["3"]={["native"]=149,["semantic"]=149}}, + ["EFFECT_HEAL_BELL"] = {["3"]={["native"]=102,["semantic"]=102}}, + ["EFFECT_LOCK_ON"] = {["2"]={["native"]="EFFECT_LOCK_ON",["semantic"]=94},["3"]={["native"]=94,["semantic"]=94}}, + ["EFFECT_MAGNITUDE"] = {["2"]={["native"]="EFFECT_MAGNITUDE",["semantic"]=126},["3"]={["native"]=126,["semantic"]=126}}, + ["EFFECT_MEAN_LOOK"] = {["2"]={["native"]="EFFECT_MEAN_LOOK",["semantic"]=106},["3"]={["native"]=106,["semantic"]=106}}, + ["EFFECT_MIRROR_COAT"] = {["2"]={["native"]="EFFECT_MIRROR_COAT",["semantic"]=144},["3"]={["native"]=144,["semantic"]=144}}, + ["EFFECT_MOONLIGHT"] = {["2"]={["native"]="EFFECT_MOONLIGHT",["semantic"]=134},["3"]={["native"]=134,["semantic"]=134}}, + ["EFFECT_MORNING_SUN"] = {["2"]={["native"]="EFFECT_MORNING_SUN",["semantic"]=132},["3"]={["native"]=132,["semantic"]=132}}, + ["EFFECT_NIGHTMARE"] = {["2"]={["native"]="EFFECT_NIGHTMARE",["semantic"]=107},["3"]={["native"]=107,["semantic"]=107}}, + ["EFFECT_PAIN_SPLIT"] = {["3"]={["native"]=91,["semantic"]=91}}, + ["EFFECT_PERISH_SONG"] = {["2"]={["native"]="EFFECT_PERISH_SONG",["semantic"]=114},["3"]={["native"]=114,["semantic"]=114}}, + ["EFFECT_PRESENT"] = {["3"]={["native"]=122,["semantic"]=122}}, + ["EFFECT_PRIORITY_HIT"] = {["2"]={["native"]="EFFECT_PRIORITY_HIT",["semantic"]=103}}, + ["EFFECT_PROTECT"] = {["2"]={["native"]="EFFECT_PROTECT",["semantic"]=111},["3"]={["native"]=111,["semantic"]=111}}, + ["EFFECT_PSYCH_UP"] = {["2"]={["native"]="EFFECT_PSYCH_UP",["semantic"]=143},["3"]={["native"]=143,["semantic"]=143}}, + ["EFFECT_PURSUIT"] = {["3"]={["native"]=128,["semantic"]=128}}, + ["EFFECT_RAIN_DANCE"] = {["2"]={["native"]="EFFECT_RAIN_DANCE",["semantic"]=136},["3"]={["native"]=136,["semantic"]=136}}, + ["EFFECT_RAPID_SPIN"] = {["3"]={["native"]=129,["semantic"]=129}}, + ["EFFECT_RETURN"] = {["2"]={["native"]="EFFECT_RETURN",["semantic"]=121},["3"]={["native"]=121,["semantic"]=121}}, + ["EFFECT_REVERSAL"] = {["3"]={["native"]=99,["semantic"]=99}}, + ["EFFECT_ROLLOUT"] = {["2"]={["native"]="EFFECT_ROLLOUT",["semantic"]=117},["3"]={["native"]=117,["semantic"]=117}}, + ["EFFECT_SACRED_FIRE"] = {["2"]={["native"]="EFFECT_SACRED_FIRE",["semantic"]=125},["3"]={["native"]=125,["semantic"]=125}}, + ["EFFECT_SAFEGUARD"] = {["2"]={["native"]="EFFECT_SAFEGUARD",["semantic"]=124},["3"]={["native"]=124,["semantic"]=124}}, + ["EFFECT_SANDSTORM"] = {["2"]={["native"]="EFFECT_SANDSTORM",["semantic"]=115},["3"]={["native"]=115,["semantic"]=115}}, + ["EFFECT_SKETCH"] = {["2"]={["native"]="EFFECT_SKETCH",["semantic"]=95},["3"]={["native"]=95,["semantic"]=95}}, + ["EFFECT_SLEEP_TALK"] = {["2"]={["native"]="EFFECT_SLEEP_TALK",["semantic"]=97},["3"]={["native"]=97,["semantic"]=97}}, + ["EFFECT_SNORE"] = {["2"]={["native"]="EFFECT_SNORE",["semantic"]=92},["3"]={["native"]=92,["semantic"]=92}}, + ["EFFECT_SPIKES"] = {["2"]={["native"]="EFFECT_SPIKES",["semantic"]=112},["3"]={["native"]=112,["semantic"]=112}}, + ["EFFECT_SPITE"] = {["2"]={["native"]="EFFECT_SPITE",["semantic"]=100},["3"]={["native"]=100,["semantic"]=100}}, + ["EFFECT_SP_ATK_DOWN_HIT"] = {["3"]={["native"]=71,["semantic"]=71}}, + ["EFFECT_SP_ATK_UP_2"] = {["3"]={["native"]=53,["semantic"]=53}}, + ["EFFECT_STOMP"] = {["3"]={["native"]=150,["semantic"]=150}}, + ["EFFECT_SUNNY_DAY"] = {["2"]={["native"]="EFFECT_SUNNY_DAY",["semantic"]=137},["3"]={["native"]=137,["semantic"]=137}}, + ["EFFECT_SWAGGER"] = {["3"]={["native"]=118,["semantic"]=118}}, + ["EFFECT_SYNTHESIS"] = {["2"]={["native"]="EFFECT_SYNTHESIS",["semantic"]=133},["3"]={["native"]=133,["semantic"]=133}}, + ["EFFECT_THIEF"] = {["2"]={["native"]="EFFECT_THIEF",["semantic"]=105},["3"]={["native"]=105,["semantic"]=105}}, + ["EFFECT_THUNDER"] = {["3"]={["native"]=152,["semantic"]=152}}, + ["EFFECT_TRIPLE_KICK"] = {["2"]={["native"]="EFFECT_TRIPLE_KICK",["semantic"]=104},["3"]={["native"]=104,["semantic"]=104}}, + ["EFFECT_TRI_ATTACK"] = {["3"]={["native"]=36,["semantic"]=36}}, + ["EFFECT_TWISTER"] = {["3"]={["native"]=146,["semantic"]=146}}, + ["ENDEAVOR"] = {["3"]={["native"]=189,["semantic"]=189}}, + ["ERUPTION"] = {["3"]={["native"]=190,["semantic"]=190}}, + ["EVASION_DOWN1_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_EVASION_DOWN",["semantic"]=24},["3"]={["chance"]=0,["native"]=24,["semantic"]=24}}, + ["EVASION_UP1_EFFECT"] = {["1"]={["native"]="EVASION_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_EVASION_UP",["semantic"]=16},["3"]={["chance"]=0,["native"]=16,["semantic"]=16}}, + ["EXPLODE_EFFECT"] = {["1"]={["native"]="EXPLODE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SELFDESTRUCT",["semantic"]=7},["3"]={["chance"]=0,["native"]=7,["semantic"]=7}}, + ["FACADE"] = {["3"]={["native"]=169,["semantic"]=169}}, + ["FAKE_OUT"] = {["3"]={["native"]=158,["semantic"]=158}}, + ["FLAIL"] = {["3"]={["native"]=99,["semantic"]=99}}, + ["FLATTER"] = {["3"]={["native"]=166,["semantic"]=166}}, + ["FLINCH_MINIMIZE_HIT"] = {["3"]={["native"]=150,["semantic"]=150}}, + ["FLINCH_SIDE_EFFECT1"] = {["1"]={["native"]="FLINCH_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_FLINCH_HIT",["semantic"]=31},["3"]={["chance"]=10,["native"]=31,["semantic"]=31}}, + ["FLINCH_SIDE_EFFECT2"] = {["1"]={["native"]="FLINCH_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_FLINCH_HIT",["semantic"]=31},["3"]={["chance"]=30,["native"]=31,["semantic"]=31}}, + ["FLY_EFFECT"] = {["1"]={["native"]="FLY_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FLY",["semantic"]=155},["3"]={["chance"]=0,["native"]=155,["semantic"]=155}}, + ["FOCUS_ENERGY_EFFECT"] = {["1"]={["native"]="FOCUS_ENERGY_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FOCUS_ENERGY",["semantic"]=47},["3"]={["chance"]=0,["native"]=47,["semantic"]=47}}, + ["FOCUS_PUNCH"] = {["3"]={["native"]=170,["semantic"]=170}}, + ["FOLLOW_ME"] = {["3"]={["native"]=172,["semantic"]=172}}, + ["FREEZE_SIDE_EFFECT1"] = {["1"]={["native"]="FREEZE_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_FREEZE_HIT",["semantic"]=5},["3"]={["chance"]=10,["native"]=5,["semantic"]=5}}, + ["FREEZE_SIDE_EFFECT2"] = {["2"]={["chance"]=30,["native"]="EFFECT_FREEZE_HIT",["semantic"]=5},["3"]={["chance"]=30,["native"]=5,["semantic"]=5}}, + ["GRUDGE"] = {["3"]={["native"]=194,["semantic"]=194}}, + ["GUST"] = {["3"]={["native"]=149,["semantic"]=149}}, + ["HAIL"] = {["3"]={["native"]=164,["semantic"]=164}}, + ["HAZE_EFFECT"] = {["1"]={["native"]="HAZE_EFFECT"},["3"]={["chance"]=0,["native"]=25,["semantic"]=25}}, + ["HEAL_BELL"] = {["3"]={["native"]=102,["semantic"]=102}}, + ["HEAL_EFFECT"] = {["1"]={["native"]="HEAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_HEAL",["semantic"]=32},["3"]={["chance"]=0,["native"]=32,["semantic"]=32}}, + ["HELPING_HAND"] = {["3"]={["native"]=176,["semantic"]=176}}, + ["HIT"] = {["1"]={["native"]="NO_ADDITIONAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_NORMAL_HIT",["semantic"]=0},["3"]={["chance"]=0,["native"]=0,["semantic"]=0}}, + ["HYPER_BEAM_EFFECT"] = {["1"]={["native"]="HYPER_BEAM_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_HYPER_BEAM",["semantic"]=80},["3"]={["chance"]=0,["native"]=80,["semantic"]=80}}, + ["IMPRISON"] = {["3"]={["native"]=192,["semantic"]=192}}, + ["INGRAIN"] = {["3"]={["native"]=181,["semantic"]=181}}, + ["JUMP_KICK_EFFECT"] = {["1"]={["native"]="JUMP_KICK_EFFECT"},["3"]={["chance"]=0,["native"]=45,["semantic"]=45}}, + ["KNOCK_OFF"] = {["3"]={["native"]=188,["semantic"]=188}}, + ["LEECH_SEED_EFFECT"] = {["1"]={["native"]="LEECH_SEED_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEECH_SEED",["semantic"]=84},["3"]={["chance"]=0,["native"]=84,["semantic"]=84}}, + ["LIGHT_SCREEN_EFFECT"] = {["1"]={["native"]="LIGHT_SCREEN_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LIGHT_SCREEN",["semantic"]=35},["3"]={["chance"]=0,["native"]=35,["semantic"]=35}}, + ["LOW_KICK"] = {["3"]={["native"]=196,["semantic"]=196}}, + ["MAGIC_COAT"] = {["3"]={["native"]=183,["semantic"]=183}}, + ["MEMENTO"] = {["3"]={["native"]=168,["semantic"]=168}}, + ["METRONOME_EFFECT"] = {["1"]={["native"]="METRONOME_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_METRONOME",["semantic"]=83},["3"]={["chance"]=0,["native"]=83,["semantic"]=83}}, + ["MIMIC_EFFECT"] = {["1"]={["native"]="MIMIC_EFFECT"},["3"]={["chance"]=0,["native"]=82,["semantic"]=82}}, + ["MINIMIZE"] = {["3"]={["native"]=108,["semantic"]=108}}, + ["MIRROR_MOVE_EFFECT"] = {["1"]={["native"]="MIRROR_MOVE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MIRROR_MOVE",["semantic"]=9},["3"]={["chance"]=0,["native"]=9,["semantic"]=9}}, + ["MIST_EFFECT"] = {["1"]={["native"]="MIST_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MIST",["semantic"]=46},["3"]={["chance"]=0,["native"]=46,["semantic"]=46}}, + ["MUD_SPORT"] = {["3"]={["native"]=201,["semantic"]=201}}, + ["NATURE_POWER"] = {["3"]={["native"]=173,["semantic"]=173}}, + ["NO_ADDITIONAL_EFFECT"] = {["1"]={["native"]="NO_ADDITIONAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_NORMAL_HIT",["semantic"]=0},["3"]={["chance"]=0,["native"]=0,["semantic"]=0}}, + ["OHKO_EFFECT"] = {["1"]={["native"]="OHKO_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_OHKO",["semantic"]=38},["3"]={["chance"]=0,["native"]=38,["semantic"]=38}}, + ["OVERHEAT"] = {["3"]={["native"]=204,["semantic"]=204}}, + ["PAIN_SPLIT"] = {["3"]={["native"]=91,["semantic"]=91}}, + ["PARALYZE_EFFECT"] = {["1"]={["native"]="PARALYZE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_PARALYZE",["semantic"]=67},["3"]={["chance"]=0,["native"]=67,["semantic"]=67}}, + ["PARALYZE_SIDE_EFFECT1"] = {["1"]={["native"]="PARALYZE_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_PARALYZE_HIT",["semantic"]=6},["3"]={["chance"]=10,["native"]=6,["semantic"]=6}}, + ["PARALYZE_SIDE_EFFECT2"] = {["1"]={["native"]="PARALYZE_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_PARALYZE_HIT",["semantic"]=6},["3"]={["chance"]=30,["native"]=6,["semantic"]=6}}, + ["PAY_DAY_EFFECT"] = {["1"]={["native"]="PAY_DAY_EFFECT"},["2"]={["chance"]=100,["native"]="EFFECT_PAY_DAY",["semantic"]=34},["3"]={["chance"]=100,["native"]=34,["semantic"]=34}}, + ["POISON_EFFECT"] = {["1"]={["native"]="POISON_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_POISON",["semantic"]=66},["3"]={["chance"]=0,["native"]=66,["semantic"]=66}}, + ["POISON_FANG"] = {["3"]={["native"]=202,["semantic"]=202}}, + ["POISON_SIDE_EFFECT1"] = {["1"]={["native"]="POISON_SIDE_EFFECT1"},["2"]={["chance"]=20,["native"]="EFFECT_POISON_HIT",["semantic"]=2},["3"]={["chance"]=20,["native"]=2,["semantic"]=2}}, + ["POISON_SIDE_EFFECT2"] = {["1"]={["native"]="POISON_SIDE_EFFECT2"},["2"]={["chance"]=40,["native"]="EFFECT_POISON_HIT",["semantic"]=2},["3"]={["chance"]=40,["native"]=2,["semantic"]=2}}, + ["POISON_TAIL"] = {["3"]={["native"]=209,["semantic"]=209}}, + ["PRESENT"] = {["3"]={["native"]=122,["semantic"]=122}}, + ["PURSUIT"] = {["3"]={["native"]=128,["semantic"]=128}}, + ["RAGE_EFFECT"] = {["1"]={["native"]="RAGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAGE",["semantic"]=81},["3"]={["chance"]=0,["native"]=81,["semantic"]=81}}, + ["RAPID_SPIN"] = {["3"]={["native"]=129,["semantic"]=129}}, + ["RECOIL_EFFECT"] = {["1"]={["native"]="RECOIL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RECOIL_HIT",["semantic"]=48},["3"]={["chance"]=0,["native"]=48,["semantic"]=48}}, + ["RECYCLE"] = {["3"]={["native"]=184,["semantic"]=184}}, + ["REFLECT_EFFECT"] = {["1"]={["native"]="REFLECT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_REFLECT",["semantic"]=65},["3"]={["chance"]=0,["native"]=65,["semantic"]=65}}, + ["REFRESH"] = {["3"]={["native"]=193,["semantic"]=193}}, + ["REVENGE"] = {["3"]={["native"]=185,["semantic"]=185}}, + ["ROLE_PLAY"] = {["3"]={["native"]=178,["semantic"]=178}}, + ["SECRET_POWER"] = {["3"]={["native"]=197,["semantic"]=197}}, + ["SKILL_SWAP"] = {["3"]={["native"]=191,["semantic"]=191}}, + ["SKY_UPPERCUT"] = {["3"]={["native"]=207,["semantic"]=207}}, + ["SLEEP"] = {["1"]={["native"]="SLEEP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SLEEP",["semantic"]=1},["3"]={["chance"]=0,["native"]=1,["semantic"]=1}}, + ["SLEEP_EFFECT"] = {["1"]={["native"]="SLEEP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SLEEP",["semantic"]=1},["3"]={["chance"]=0,["native"]=1,["semantic"]=1}}, + ["SMELLINGSALT"] = {["3"]={["native"]=171,["semantic"]=171}}, + ["SNATCH"] = {["3"]={["native"]=195,["semantic"]=195}}, + ["SOFTBOILED"] = {["3"]={["native"]=157,["semantic"]=157}}, + ["SPECIAL_ATTACK_DOWN_HIT"] = {["3"]={["native"]=71,["semantic"]=71}}, + ["SPECIAL_ATTACK_UP_2"] = {["3"]={["native"]=53,["semantic"]=53}}, + ["SPECIAL_DAMAGE_EFFECT"] = {["1"]={["native"]="SPECIAL_DAMAGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEVEL_DAMAGE",["semantic"]=87},["3"]={["chance"]=0,["native"]=87,["semantic"]=87}}, + ["SPECIAL_DOWN2_EFFECT"] = {["3"]={["chance"]=0,["native"]=62,["semantic"]=62}}, + ["SPECIAL_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="SPECIAL_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_SP_DEF_DOWN_HIT",["semantic"]=72},["3"]={["chance"]=33,["native"]=72,["semantic"]=72}}, + ["SPECIAL_UP1_EFFECT"] = {["1"]={["native"]="SPECIAL_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SP_ATK_UP",["semantic"]=13},["3"]={["chance"]=0,["native"]=13,["semantic"]=13}}, + ["SPECIAL_UP2_EFFECT"] = {["1"]={["native"]="SPECIAL_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SP_DEF_UP_2",["semantic"]=54},["3"]={["chance"]=0,["native"]=54,["semantic"]=54}}, + ["SPEED_DOWN1_EFFECT"] = {["1"]={["native"]="SPEED_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPEED_DOWN",["semantic"]=20},["3"]={["chance"]=0,["native"]=20,["semantic"]=20}}, + ["SPEED_DOWN2_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_SPEED_DOWN_2",["semantic"]=60},["3"]={["chance"]=0,["native"]=60,["semantic"]=60}}, + ["SPEED_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="SPEED_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_SPEED_DOWN_HIT",["semantic"]=70},["3"]={["chance"]=33,["native"]=70,["semantic"]=70}}, + ["SPEED_UP2_EFFECT"] = {["1"]={["native"]="SPEED_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPEED_UP_2",["semantic"]=52},["3"]={["chance"]=0,["native"]=52,["semantic"]=52}}, + ["SPIT_UP"] = {["3"]={["native"]=161,["semantic"]=161}}, + ["SPLASH_EFFECT"] = {["1"]={["native"]="SPLASH_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPLASH",["semantic"]=85},["3"]={["chance"]=0,["native"]=85,["semantic"]=85}}, + ["STOCKPILE"] = {["3"]={["native"]=160,["semantic"]=160}}, + ["SUBSTITUTE_EFFECT"] = {["1"]={["native"]="SUBSTITUTE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SUBSTITUTE",["semantic"]=79},["3"]={["chance"]=0,["native"]=79,["semantic"]=79}}, + ["SUPERPOWER"] = {["3"]={["native"]=182,["semantic"]=182}}, + ["SUPER_FANG_EFFECT"] = {["1"]={["native"]="SUPER_FANG_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SUPER_FANG",["semantic"]=40},["3"]={["chance"]=0,["native"]=40,["semantic"]=40}}, + ["SWAGGER"] = {["3"]={["native"]=118,["semantic"]=118}}, + ["SWALLOW"] = {["3"]={["native"]=162,["semantic"]=162}}, + ["SWIFT_EFFECT"] = {["1"]={["native"]="SWIFT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ALWAYS_HIT",["semantic"]=17},["3"]={["chance"]=0,["native"]=17,["semantic"]=17}}, + ["SWITCH_AND_TELEPORT_EFFECT"] = {["1"]={["native"]="SWITCH_AND_TELEPORT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FORCE_SWITCH",["semantic"]=28},["3"]={["chance"]=0,["native"]=28,["semantic"]=28}}, + ["TAUNT"] = {["3"]={["native"]=175,["semantic"]=175}}, + ["TEETER_DANCE"] = {["3"]={["native"]=199,["semantic"]=199}}, + ["THRASH_PETAL_DANCE_EFFECT"] = {["1"]={["native"]="THRASH_PETAL_DANCE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAMPAGE",["semantic"]=27},["3"]={["chance"]=0,["native"]=27,["semantic"]=27}}, + ["THUNDER"] = {["3"]={["native"]=152,["semantic"]=152}}, + ["TICKLE"] = {["3"]={["native"]=205,["semantic"]=205}}, + ["TORMENT"] = {["3"]={["native"]=165,["semantic"]=165}}, + ["TRANSFORM_EFFECT"] = {["1"]={["native"]="TRANSFORM_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_TRANSFORM",["semantic"]=57},["3"]={["chance"]=0,["native"]=57,["semantic"]=57}}, + ["TRAPPING_EFFECT"] = {["1"]={["native"]="TRAPPING_EFFECT"},["2"]={["chance"]=100,["native"]="EFFECT_TRAP_TARGET",["semantic"]=42},["3"]={["chance"]=100,["native"]=42,["semantic"]=42}}, + ["TRICK"] = {["3"]={["native"]=177,["semantic"]=177}}, + ["TRI_ATTACK"] = {["3"]={["native"]=36,["semantic"]=36}}, + ["TWINEEDLE_EFFECT"] = {["1"]={["native"]="TWINEEDLE_EFFECT"},["3"]={["chance"]=20,["native"]=77,["semantic"]=77}}, + ["TWISTER"] = {["3"]={["native"]=146,["semantic"]=146}}, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = {["1"]={["native"]="TWO_TO_FIVE_ATTACKS_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MULTI_HIT",["semantic"]=29},["3"]={["chance"]=0,["native"]=29,["semantic"]=29}}, + ["UPROAR"] = {["3"]={["native"]=159,["semantic"]=159}}, + ["VITAL_THROW"] = {["3"]={["native"]=78,["semantic"]=78}}, + ["WATER_SPORT"] = {["3"]={["native"]=210,["semantic"]=210}}, + ["WEATHER_BALL"] = {["3"]={["native"]=203,["semantic"]=203}}, + ["WISH"] = {["3"]={["native"]=179,["semantic"]=179}}, + ["YAWN"] = {["3"]={["native"]=187,["semantic"]=187}}, + }, + ["hostMapSha256"] = "640d77b2a86740d18458262bdf424070ad9c52164365dbdb4879f3af2ba038ee", + ["native3"] = { + ["0"] = {"NO_ADDITIONAL_EFFECT"}, + ["1"] = {"SLEEP_EFFECT"}, + ["10"] = {"ATTACK_UP1_EFFECT"}, + ["100"] = {"SPITE_EFFECT"}, + ["101"] = {"FALSE_SWIPE_EFFECT"}, + ["102"] = {"HEAL_BELL_EFFECT"}, + ["103"] = {"NO_ADDITIONAL_EFFECT"}, + ["104"] = {"TRIPLE_KICK_EFFECT"}, + ["105"] = {"THIEF_EFFECT"}, + ["106"] = {"MEAN_LOOK_EFFECT"}, + ["107"] = {"NIGHTMARE_EFFECT"}, + ["108"] = {"EVASION_UP1_EFFECT"}, + ["109"] = {"CURSE_EFFECT"}, + ["11"] = {"DEFENSE_UP1_EFFECT"}, + ["111"] = {"PROTECT_EFFECT"}, + ["112"] = {"SPIKES_EFFECT"}, + ["113"] = {"FORESIGHT_EFFECT"}, + ["114"] = {"PERISH_SONG_EFFECT"}, + ["115"] = {"SANDSTORM_EFFECT"}, + ["116"] = {"ENDURE_EFFECT"}, + ["117"] = {"ROLLOUT_EFFECT"}, + ["118"] = {"SWAGGER_EFFECT"}, + ["119"] = {"FURY_CUTTER_EFFECT"}, + ["120"] = {"ATTRACT_EFFECT"}, + ["121"] = {"RETURN_EFFECT"}, + ["122"] = {"PRESENT_EFFECT"}, + ["123"] = {"FRUSTRATION_EFFECT"}, + ["124"] = {"SAFEGUARD_EFFECT"}, + ["125"] = {"BURN_SIDE_EFFECT1","BURN_SIDE_EFFECT2"}, + ["126"] = {"MAGNITUDE_EFFECT"}, + ["127"] = {"BATON_PASS_EFFECT"}, + ["128"] = {"PURSUIT_EFFECT"}, + ["129"] = {"RAPID_SPIN_EFFECT"}, + ["13"] = {"SP_ATK_UP1_EFFECT"}, + ["130"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["132"] = {"MORNING_SUN_EFFECT"}, + ["133"] = {"SYNTHESIS_EFFECT"}, + ["134"] = {"MOONLIGHT_EFFECT"}, + ["135"] = {"HIDDEN_POWER_EFFECT"}, + ["136"] = {"RAIN_DANCE_EFFECT"}, + ["137"] = {"SUNNY_DAY_EFFECT"}, + ["138"] = {"DEFENSE_UP_HIT_EFFECT"}, + ["139"] = {"ATTACK_UP_HIT_EFFECT"}, + ["140"] = {"ALL_UP_HIT_EFFECT"}, + ["142"] = {"BELLY_DRUM_EFFECT"}, + ["143"] = {"PSYCH_UP_EFFECT"}, + ["144"] = {"MIRROR_COAT_EFFECT"}, + ["145"] = {"CHARGE_EFFECT"}, + ["146"] = {"FLINCH_SIDE_EFFECT1"}, + ["147"] = {"EARTHQUAKE_EFFECT"}, + ["148"] = {"FUTURE_SIGHT_EFFECT"}, + ["149"] = {"GUST_EFFECT"}, + ["150"] = {"FLINCH_SIDE_EFFECT2"}, + ["151"] = {"SOLARBEAM_EFFECT"}, + ["152"] = {"THUNDER_EFFECT"}, + ["153"] = {"SWITCH_AND_TELEPORT_EFFECT"}, + ["154"] = {"BEAT_UP_EFFECT"}, + ["155"] = {"FLY_EFFECT"}, + ["156"] = {"DEFENSE_UP1_EFFECT"}, + ["157"] = {"HEAL_EFFECT"}, + ["158"] = {"FAKE_OUT_EFFECT"}, + ["159"] = {"UPROAR_EFFECT"}, + ["16"] = {"EVASION_UP1_EFFECT"}, + ["160"] = {"STOCKPILE_EFFECT"}, + ["161"] = {"SPIT_UP_EFFECT"}, + ["162"] = {"SWALLOW_EFFECT"}, + ["164"] = {"HAIL_EFFECT"}, + ["165"] = {"TORMENT_EFFECT"}, + ["166"] = {"FLATTER_EFFECT"}, + ["167"] = {"WILL_O_WISP_EFFECT"}, + ["168"] = {"MEMENTO_EFFECT"}, + ["169"] = {"FACADE_EFFECT"}, + ["17"] = {"SWIFT_EFFECT"}, + ["170"] = {"FOCUS_PUNCH_EFFECT"}, + ["171"] = {"SMELLINGSALT_EFFECT"}, + ["172"] = {"FOLLOW_ME_EFFECT"}, + ["173"] = {"NATURE_POWER_EFFECT"}, + ["174"] = {"CHARGE_UP_EFFECT"}, + ["175"] = {"TAUNT_EFFECT"}, + ["176"] = {"HELPING_HAND_EFFECT"}, + ["177"] = {"TRICK_EFFECT"}, + ["178"] = {"ROLE_PLAY_EFFECT"}, + ["179"] = {"WISH_EFFECT"}, + ["18"] = {"ATTACK_DOWN1_EFFECT"}, + ["180"] = {"ASSIST_EFFECT"}, + ["181"] = {"INGRAIN_EFFECT"}, + ["182"] = {"SUPERPOWER_EFFECT"}, + ["183"] = {"MAGIC_COAT_EFFECT"}, + ["184"] = {"RECYCLE_EFFECT"}, + ["185"] = {"REVENGE_EFFECT"}, + ["186"] = {"BRICK_BREAK_EFFECT"}, + ["187"] = {"YAWN_EFFECT"}, + ["188"] = {"KNOCK_OFF_EFFECT"}, + ["189"] = {"ENDEAVOR_EFFECT"}, + ["19"] = {"DEFENSE_DOWN1_EFFECT"}, + ["190"] = {"ERUPTION_EFFECT"}, + ["191"] = {"SKILL_SWAP_EFFECT"}, + ["192"] = {"IMPRISON_EFFECT"}, + ["193"] = {"REFRESH_EFFECT"}, + ["194"] = {"GRUDGE_EFFECT"}, + ["195"] = {"SNATCH_EFFECT"}, + ["196"] = {"LOW_KICK_EFFECT"}, + ["197"] = {"SECRET_POWER_EFFECT"}, + ["198"] = {"RECOIL_EFFECT"}, + ["199"] = {"TEETER_DANCE_EFFECT"}, + ["2"] = {"POISON_SIDE_EFFECT2"}, + ["20"] = {"SPEED_DOWN1_EFFECT"}, + ["200"] = {"BLAZE_KICK_EFFECT"}, + ["201"] = {"MUD_SPORT_EFFECT"}, + ["202"] = {"POISON_FANG_EFFECT"}, + ["203"] = {"WEATHER_BALL_EFFECT"}, + ["204"] = {"OVERHEAT_EFFECT"}, + ["205"] = {"TICKLE_EFFECT"}, + ["206"] = {"COSMIC_POWER_EFFECT"}, + ["207"] = {"SKY_UPPERCUT_EFFECT"}, + ["208"] = {"BULK_UP_EFFECT"}, + ["209"] = {"POISON_TAIL_EFFECT"}, + ["210"] = {"WATER_SPORT_EFFECT"}, + ["211"] = {"CALM_MIND_EFFECT"}, + ["212"] = {"DRAGON_DANCE_EFFECT"}, + ["213"] = {"CAMOUFLAGE_EFFECT"}, + ["23"] = {"ACCURACY_DOWN1_EFFECT"}, + ["24"] = {"EVASION_DOWN1_EFFECT"}, + ["25"] = {"HAZE_EFFECT"}, + ["26"] = {"BIDE_EFFECT"}, + ["27"] = {"THRASH_PETAL_DANCE_EFFECT"}, + ["28"] = {"SWITCH_AND_TELEPORT_EFFECT"}, + ["29"] = {"TWO_TO_FIVE_ATTACKS_EFFECT"}, + ["3"] = {"DRAIN_HP_EFFECT"}, + ["30"] = {"CONVERSION_EFFECT"}, + ["31"] = {"FLINCH_SIDE_EFFECT1","FLINCH_SIDE_EFFECT2"}, + ["32"] = {"HEAL_EFFECT"}, + ["33"] = {"POISON_EFFECT"}, + ["34"] = {"PAY_DAY_EFFECT"}, + ["35"] = {"LIGHT_SCREEN_EFFECT"}, + ["36"] = {"TRI_ATTACK_EFFECT"}, + ["37"] = {"HEAL_EFFECT"}, + ["38"] = {"OHKO_EFFECT"}, + ["39"] = {"CHARGE_EFFECT"}, + ["4"] = {"BURN_SIDE_EFFECT1"}, + ["40"] = {"SUPER_FANG_EFFECT"}, + ["41"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["42"] = {"TRAPPING_EFFECT"}, + ["43"] = {"HIGH_CRITICAL_EFFECT"}, + ["44"] = {"ATTACK_TWICE_EFFECT"}, + ["45"] = {"JUMP_KICK_EFFECT"}, + ["46"] = {"MIST_EFFECT"}, + ["47"] = {"FOCUS_ENERGY_EFFECT"}, + ["48"] = {"RECOIL_EFFECT"}, + ["49"] = {"CONFUSION_EFFECT"}, + ["5"] = {"FREEZE_SIDE_EFFECT1"}, + ["50"] = {"ATTACK_UP2_EFFECT"}, + ["51"] = {"DEFENSE_UP2_EFFECT"}, + ["52"] = {"SPEED_UP2_EFFECT"}, + ["53"] = {"SP_ATK_UP2_EFFECT"}, + ["54"] = {"SP_DEF_UP2_EFFECT"}, + ["57"] = {"TRANSFORM_EFFECT"}, + ["58"] = {"ATTACK_DOWN2_EFFECT"}, + ["59"] = {"DEFENSE_DOWN2_EFFECT"}, + ["6"] = {"PARALYZE_SIDE_EFFECT1","PARALYZE_SIDE_EFFECT2"}, + ["60"] = {"SPEED_DOWN2_EFFECT"}, + ["62"] = {"SP_DEF_DOWN2_EFFECT"}, + ["65"] = {"REFLECT_EFFECT"}, + ["66"] = {"POISON_EFFECT"}, + ["67"] = {"PARALYZE_EFFECT"}, + ["68"] = {"ATTACK_DOWN_SIDE_EFFECT"}, + ["69"] = {"DEFENSE_DOWN_SIDE_EFFECT"}, + ["7"] = {"EXPLODE_EFFECT"}, + ["70"] = {"SPEED_DOWN_SIDE_EFFECT"}, + ["71"] = {"SP_ATK_DOWN_HIT_EFFECT"}, + ["72"] = {"SP_DEF_DOWN_SIDE_EFFECT"}, + ["73"] = {"ACCURACY_DOWN_SIDE_EFFECT"}, + ["75"] = {"CHARGE_EFFECT"}, + ["76"] = {"CONFUSION_SIDE_EFFECT"}, + ["77"] = {"TWINEEDLE_EFFECT"}, + ["78"] = {"SWIFT_EFFECT"}, + ["79"] = {"SUBSTITUTE_EFFECT"}, + ["8"] = {"DREAM_EATER_EFFECT"}, + ["80"] = {"HYPER_BEAM_EFFECT"}, + ["81"] = {"RAGE_EFFECT"}, + ["82"] = {"MIMIC_EFFECT"}, + ["83"] = {"METRONOME_EFFECT"}, + ["84"] = {"LEECH_SEED_EFFECT"}, + ["85"] = {"SPLASH_EFFECT"}, + ["86"] = {"DISABLE_EFFECT"}, + ["87"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["88"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["89"] = {"COUNTER_EFFECT"}, + ["9"] = {"MIRROR_MOVE_EFFECT"}, + ["90"] = {"ENCORE_EFFECT"}, + ["91"] = {"PAIN_SPLIT_EFFECT"}, + ["92"] = {"SNORE_EFFECT"}, + ["93"] = {"CONVERSION2_EFFECT"}, + ["94"] = {"LOCK_ON_EFFECT"}, + ["95"] = {"SKETCH_EFFECT"}, + ["97"] = {"SLEEP_TALK_EFFECT"}, + ["98"] = {"DESTINY_BOND_EFFECT"}, + ["99"] = {"REVERSAL_EFFECT"}, + }, + ["read3"] = { + ["0"] = "NO_ADDITIONAL_EFFECT", + ["1"] = "SLEEP_EFFECT", + ["10"] = "ATTACK_UP1_EFFECT", + ["100"] = "EFFECT_SPITE", + ["101"] = "EFFECT_FALSE_SWIPE", + ["102"] = "HEAL_BELL", + ["103"] = "EFFECT_PRIORITY_HIT", + ["104"] = "EFFECT_TRIPLE_KICK", + ["105"] = "EFFECT_THIEF", + ["106"] = "EFFECT_MEAN_LOOK", + ["107"] = "EFFECT_NIGHTMARE", + ["108"] = "MINIMIZE", + ["109"] = "EFFECT_CURSE", + ["11"] = "DEFENSE_UP1_EFFECT", + ["110"] = "native:3:110", + ["111"] = "EFFECT_PROTECT", + ["112"] = "EFFECT_SPIKES", + ["113"] = "EFFECT_FORESIGHT", + ["114"] = "EFFECT_PERISH_SONG", + ["115"] = "EFFECT_SANDSTORM", + ["116"] = "EFFECT_ENDURE", + ["117"] = "EFFECT_ROLLOUT", + ["118"] = "SWAGGER", + ["119"] = "EFFECT_FURY_CUTTER", + ["12"] = "native:3:SPEED_UP", + ["120"] = "EFFECT_ATTRACT", + ["121"] = "EFFECT_RETURN", + ["122"] = "PRESENT", + ["123"] = "EFFECT_FRUSTRATION", + ["124"] = "EFFECT_SAFEGUARD", + ["125"] = "EFFECT_SACRED_FIRE", + ["126"] = "EFFECT_MAGNITUDE", + ["127"] = "EFFECT_BATON_PASS", + ["128"] = "PURSUIT", + ["129"] = "RAPID_SPIN", + ["13"] = "SPECIAL_UP1_EFFECT", + ["130"] = "native:3:SONICBOOM", + ["131"] = "native:3:131", + ["132"] = "EFFECT_MORNING_SUN", + ["133"] = "EFFECT_SYNTHESIS", + ["134"] = "EFFECT_MOONLIGHT", + ["135"] = "native:3:HIDDEN_POWER", + ["136"] = "EFFECT_RAIN_DANCE", + ["137"] = "EFFECT_SUNNY_DAY", + ["138"] = "EFFECT_DEFENSE_UP_HIT", + ["139"] = "EFFECT_ATTACK_UP_HIT", + ["14"] = "native:3:SPECIAL_DEFENSE_UP", + ["140"] = "EFFECT_ALL_UP_HIT", + ["141"] = "native:3:141", + ["142"] = "EFFECT_BELLY_DRUM", + ["143"] = "EFFECT_PSYCH_UP", + ["144"] = "EFFECT_MIRROR_COAT", + ["145"] = "native:3:SKULL_BASH", + ["146"] = "TWISTER", + ["147"] = "EARTHQUAKE", + ["148"] = "EFFECT_FUTURE_SIGHT", + ["149"] = "GUST", + ["15"] = "native:3:ACCURACY_UP", + ["150"] = "FLINCH_MINIMIZE_HIT", + ["151"] = "native:3:SOLAR_BEAM", + ["152"] = "THUNDER", + ["153"] = "native:3:TELEPORT", + ["154"] = "EFFECT_BEAT_UP", + ["155"] = "FLY_EFFECT", + ["156"] = "EFFECT_DEFENSE_CURL", + ["157"] = "SOFTBOILED", + ["158"] = "FAKE_OUT", + ["159"] = "UPROAR", + ["16"] = "EVASION_UP1_EFFECT", + ["160"] = "STOCKPILE", + ["161"] = "SPIT_UP", + ["162"] = "SWALLOW", + ["163"] = "native:3:163", + ["164"] = "HAIL", + ["165"] = "TORMENT", + ["166"] = "FLATTER", + ["167"] = "EFFECT_BURN", + ["168"] = "MEMENTO", + ["169"] = "FACADE", + ["17"] = "SWIFT_EFFECT", + ["170"] = "FOCUS_PUNCH", + ["171"] = "SMELLINGSALT", + ["172"] = "FOLLOW_ME", + ["173"] = "NATURE_POWER", + ["174"] = "CHARGE", + ["175"] = "TAUNT", + ["176"] = "HELPING_HAND", + ["177"] = "TRICK", + ["178"] = "ROLE_PLAY", + ["179"] = "WISH", + ["18"] = "ATTACK_DOWN1_EFFECT", + ["180"] = "ASSIST", + ["181"] = "INGRAIN", + ["182"] = "SUPERPOWER", + ["183"] = "MAGIC_COAT", + ["184"] = "RECYCLE", + ["185"] = "REVENGE", + ["186"] = "BRICK_BREAK", + ["187"] = "YAWN", + ["188"] = "KNOCK_OFF", + ["189"] = "ENDEAVOR", + ["19"] = "DEFENSE_DOWN1_EFFECT", + ["190"] = "ERUPTION", + ["191"] = "SKILL_SWAP", + ["192"] = "IMPRISON", + ["193"] = "REFRESH", + ["194"] = "GRUDGE", + ["195"] = "SNATCH", + ["196"] = "LOW_KICK", + ["197"] = "SECRET_POWER", + ["198"] = "DOUBLE_EDGE", + ["199"] = "TEETER_DANCE", + ["2"] = "POISON_SIDE_EFFECT1", + ["20"] = "SPEED_DOWN1_EFFECT", + ["200"] = "BLAZE_KICK", + ["201"] = "MUD_SPORT", + ["202"] = "POISON_FANG", + ["203"] = "WEATHER_BALL", + ["204"] = "OVERHEAT", + ["205"] = "TICKLE", + ["206"] = "COSMIC_POWER", + ["207"] = "SKY_UPPERCUT", + ["208"] = "BULK_UP", + ["209"] = "POISON_TAIL", + ["21"] = "native:3:SPECIAL_ATTACK_DOWN", + ["210"] = "WATER_SPORT", + ["211"] = "CALM_MIND", + ["212"] = "DRAGON_DANCE", + ["213"] = "CAMOUFLAGE", + ["214"] = "native:3:214", + ["215"] = "native:3:215", + ["216"] = "native:3:216", + ["217"] = "native:3:217", + ["218"] = "native:3:218", + ["219"] = "native:3:219", + ["22"] = "native:3:SPECIAL_DEFENSE_DOWN", + ["220"] = "native:3:220", + ["221"] = "native:3:221", + ["222"] = "native:3:222", + ["223"] = "native:3:223", + ["224"] = "native:3:224", + ["225"] = "native:3:225", + ["226"] = "native:3:226", + ["227"] = "native:3:227", + ["228"] = "native:3:228", + ["229"] = "native:3:229", + ["23"] = "ACCURACY_DOWN1_EFFECT", + ["230"] = "native:3:230", + ["231"] = "native:3:231", + ["232"] = "native:3:232", + ["233"] = "native:3:233", + ["234"] = "native:3:234", + ["235"] = "native:3:235", + ["236"] = "native:3:236", + ["237"] = "native:3:237", + ["238"] = "native:3:238", + ["239"] = "native:3:239", + ["24"] = "EVASION_DOWN1_EFFECT", + ["240"] = "native:3:240", + ["241"] = "native:3:241", + ["242"] = "native:3:242", + ["243"] = "native:3:243", + ["244"] = "native:3:244", + ["245"] = "native:3:245", + ["246"] = "native:3:246", + ["247"] = "native:3:247", + ["248"] = "native:3:248", + ["249"] = "native:3:249", + ["25"] = "HAZE_EFFECT", + ["250"] = "native:3:250", + ["251"] = "native:3:251", + ["252"] = "native:3:252", + ["253"] = "native:3:253", + ["254"] = "native:3:254", + ["255"] = "native:3:255", + ["26"] = "BIDE_EFFECT", + ["27"] = "THRASH_PETAL_DANCE_EFFECT", + ["28"] = "SWITCH_AND_TELEPORT_EFFECT", + ["29"] = "TWO_TO_FIVE_ATTACKS_EFFECT", + ["3"] = "DRAIN_HP_EFFECT", + ["30"] = "CONVERSION_EFFECT", + ["31"] = "FLINCH_SIDE_EFFECT1", + ["32"] = "HEAL_EFFECT", + ["33"] = "native:3:TOXIC", + ["34"] = "PAY_DAY_EFFECT", + ["35"] = "LIGHT_SCREEN_EFFECT", + ["36"] = "TRI_ATTACK", + ["37"] = "native:3:REST", + ["38"] = "OHKO_EFFECT", + ["39"] = "CHARGE_EFFECT", + ["4"] = "BURN_SIDE_EFFECT1", + ["40"] = "SUPER_FANG_EFFECT", + ["41"] = "native:3:DRAGON_RAGE", + ["42"] = "TRAPPING_EFFECT", + ["43"] = "native:3:HIGH_CRITICAL", + ["44"] = "ATTACK_TWICE_EFFECT", + ["45"] = "JUMP_KICK_EFFECT", + ["46"] = "MIST_EFFECT", + ["47"] = "FOCUS_ENERGY_EFFECT", + ["48"] = "RECOIL_EFFECT", + ["49"] = "CONFUSION_EFFECT", + ["5"] = "FREEZE_SIDE_EFFECT1", + ["50"] = "ATTACK_UP2_EFFECT", + ["51"] = "DEFENSE_UP2_EFFECT", + ["52"] = "SPEED_UP2_EFFECT", + ["53"] = "SPECIAL_ATTACK_UP_2", + ["54"] = "SPECIAL_UP2_EFFECT", + ["55"] = "native:3:ACCURACY_UP_2", + ["56"] = "native:3:EVASION_UP_2", + ["57"] = "TRANSFORM_EFFECT", + ["58"] = "ATTACK_DOWN2_EFFECT", + ["59"] = "DEFENSE_DOWN2_EFFECT", + ["6"] = "PARALYZE_SIDE_EFFECT1", + ["60"] = "SPEED_DOWN2_EFFECT", + ["61"] = "native:3:SPECIAL_ATTACK_DOWN_2", + ["62"] = "SPECIAL_DOWN2_EFFECT", + ["63"] = "native:3:ACCURACY_DOWN_2", + ["64"] = "native:3:EVASION_DOWN_2", + ["65"] = "REFLECT_EFFECT", + ["66"] = "POISON_EFFECT", + ["67"] = "PARALYZE_EFFECT", + ["68"] = "ATTACK_DOWN_SIDE_EFFECT", + ["69"] = "DEFENSE_DOWN_SIDE_EFFECT", + ["7"] = "EXPLODE_EFFECT", + ["70"] = "SPEED_DOWN_SIDE_EFFECT", + ["71"] = "SPECIAL_ATTACK_DOWN_HIT", + ["72"] = "SPECIAL_DOWN_SIDE_EFFECT", + ["73"] = "EFFECT_ACCURACY_DOWN_HIT", + ["74"] = "native:3:EVASION_DOWN_HIT", + ["75"] = "native:3:SKY_ATTACK", + ["76"] = "CONFUSION_SIDE_EFFECT", + ["77"] = "TWINEEDLE_EFFECT", + ["78"] = "VITAL_THROW", + ["79"] = "SUBSTITUTE_EFFECT", + ["8"] = "DREAM_EATER_EFFECT", + ["80"] = "HYPER_BEAM_EFFECT", + ["81"] = "RAGE_EFFECT", + ["82"] = "MIMIC_EFFECT", + ["83"] = "METRONOME_EFFECT", + ["84"] = "LEECH_SEED_EFFECT", + ["85"] = "SPLASH_EFFECT", + ["86"] = "DISABLE_EFFECT", + ["87"] = "SPECIAL_DAMAGE_EFFECT", + ["88"] = "native:3:PSYWAVE", + ["89"] = "native:3:COUNTER", + ["9"] = "MIRROR_MOVE_EFFECT", + ["90"] = "EFFECT_ENCORE", + ["91"] = "PAIN_SPLIT", + ["92"] = "EFFECT_SNORE", + ["93"] = "CONVERSION_2", + ["94"] = "EFFECT_LOCK_ON", + ["95"] = "EFFECT_SKETCH", + ["96"] = "native:3:96", + ["97"] = "EFFECT_SLEEP_TALK", + ["98"] = "DESTINY_BOND", + ["99"] = "FLAIL", + }, + ["reference"] = { + ["commit"] = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36", + ["sha256"] = {["src/battle/g3u/EffectMap1.lua"]="15a66e3c011128a94f5d84689581c78e1f7f737da7c17d60d46b8cdf3ad22cfd",["src/battle/g3u/EffectMap2.lua"]="009ed83f5a76638050110b4304e66bc514b61ded28bbf4e4b75e5d1f448cb708",["src/core/game3/battle/effect_ids.lua"]="00ce6a4196c2b05d56c8b575ec3eb20d8e32d16a99646716f18f08624795f496",["src/mods/UnifiedEffects.lua"]="bde10a80bd8c64a71a279b8a143757a8f26f30848c095ce49b6a085690181676"}, + }, + ["reserved"] = { + ["ABSORB"] = true, + ["ACCURACY_DOWN"] = true, + ["ACCURACY_DOWN1_EFFECT"] = true, + ["ACCURACY_DOWN2_EFFECT"] = true, + ["ACCURACY_DOWN_2"] = true, + ["ACCURACY_DOWN_HIT"] = true, + ["ACCURACY_UP"] = true, + ["ACCURACY_UP1_EFFECT"] = true, + ["ACCURACY_UP2_EFFECT"] = true, + ["ACCURACY_UP_2"] = true, + ["AFTER_HIT"] = true, + ["ALL_STATS_UP_HIT"] = true, + ["ALWAYS_HIT"] = true, + ["ASSIST"] = true, + ["ATTACK_DOWN"] = true, + ["ATTACK_DOWN1_EFFECT"] = true, + ["ATTACK_DOWN2_EFFECT"] = true, + ["ATTACK_DOWN_2"] = true, + ["ATTACK_DOWN_HIT"] = true, + ["ATTACK_DOWN_SIDE_EFFECT"] = true, + ["ATTACK_TWICE_EFFECT"] = true, + ["ATTACK_UP"] = true, + ["ATTACK_UP1_EFFECT"] = true, + ["ATTACK_UP2_EFFECT"] = true, + ["ATTACK_UP_2"] = true, + ["ATTACK_UP_HIT"] = true, + ["ATTRACT"] = true, + ["BATON_PASS"] = true, + ["BEAT_UP"] = true, + ["BELLY_DRUM"] = true, + ["BIDE"] = true, + ["BIDE_EFFECT"] = true, + ["BLAZE_KICK"] = true, + ["BRICK_BREAK"] = true, + ["BULK_UP"] = true, + ["BURN_HIT"] = true, + ["BURN_SIDE_EFFECT1"] = true, + ["BURN_SIDE_EFFECT2"] = true, + ["CALM_MIND"] = true, + ["CAMOUFLAGE"] = true, + ["CHARGE"] = true, + ["CHARGE_EFFECT"] = true, + ["CONFUSE"] = true, + ["CONFUSE_HIT"] = true, + ["CONFUSION_EFFECT"] = true, + ["CONFUSION_SIDE_EFFECT"] = true, + ["CONVERSION"] = true, + ["CONVERSION_2"] = true, + ["CONVERSION_EFFECT"] = true, + ["COSMIC_POWER"] = true, + ["COUNTER"] = true, + ["CURSE"] = true, + ["DEFENSE_CURL"] = true, + ["DEFENSE_DOWN"] = true, + ["DEFENSE_DOWN1_EFFECT"] = true, + ["DEFENSE_DOWN2_EFFECT"] = true, + ["DEFENSE_DOWN_2"] = true, + ["DEFENSE_DOWN_HIT"] = true, + ["DEFENSE_DOWN_SIDE_EFFECT"] = true, + ["DEFENSE_UP"] = true, + ["DEFENSE_UP1_EFFECT"] = true, + ["DEFENSE_UP2_EFFECT"] = true, + ["DEFENSE_UP_2"] = true, + ["DEFENSE_UP_HIT"] = true, + ["DESTINY_BOND"] = true, + ["DISABLE"] = true, + ["DISABLE_EFFECT"] = true, + ["DOUBLE_EDGE"] = true, + ["DOUBLE_HIT"] = true, + ["DRAGON_DANCE"] = true, + ["DRAGON_RAGE"] = true, + ["DRAIN_HP_EFFECT"] = true, + ["DREAM_EATER"] = true, + ["DREAM_EATER_EFFECT"] = true, + ["EARTHQUAKE"] = true, + ["EFFECT_01"] = true, + ["EFFECT_1E"] = true, + ["EFFECT_ACCURACY_DOWN"] = true, + ["EFFECT_ACCURACY_DOWN_2"] = true, + ["EFFECT_ACCURACY_DOWN_HIT"] = true, + ["EFFECT_ACCURACY_UP"] = true, + ["EFFECT_ACCURACY_UP_2"] = true, + ["EFFECT_ALL_UP_HIT"] = true, + ["EFFECT_ALWAYS_HIT"] = true, + ["EFFECT_ATTACK_DOWN"] = true, + ["EFFECT_ATTACK_DOWN_2"] = true, + ["EFFECT_ATTACK_DOWN_HIT"] = true, + ["EFFECT_ATTACK_UP"] = true, + ["EFFECT_ATTACK_UP_2"] = true, + ["EFFECT_ATTACK_UP_HIT"] = true, + ["EFFECT_ATTRACT"] = true, + ["EFFECT_BATON_PASS"] = true, + ["EFFECT_BEAT_UP"] = true, + ["EFFECT_BELLY_DRUM"] = true, + ["EFFECT_BIDE"] = true, + ["EFFECT_BURN_HIT"] = true, + ["EFFECT_CONFUSE"] = true, + ["EFFECT_CONFUSE_HIT"] = true, + ["EFFECT_CONVERSION"] = true, + ["EFFECT_CONVERSION2"] = true, + ["EFFECT_COUNTER"] = true, + ["EFFECT_CURSE"] = true, + ["EFFECT_DEFENSE_CURL"] = true, + ["EFFECT_DEFENSE_DOWN"] = true, + ["EFFECT_DEFENSE_DOWN_2"] = true, + ["EFFECT_DEFENSE_DOWN_HIT"] = true, + ["EFFECT_DEFENSE_UP"] = true, + ["EFFECT_DEFENSE_UP_2"] = true, + ["EFFECT_DEFENSE_UP_HIT"] = true, + ["EFFECT_DEFROST_OPPONENT"] = true, + ["EFFECT_DESTINY_BOND"] = true, + ["EFFECT_DISABLE"] = true, + ["EFFECT_DOUBLE_HIT"] = true, + ["EFFECT_DREAM_EATER"] = true, + ["EFFECT_EARTHQUAKE"] = true, + ["EFFECT_ENCORE"] = true, + ["EFFECT_ENDURE"] = true, + ["EFFECT_EVASION_DOWN"] = true, + ["EFFECT_EVASION_DOWN_2"] = true, + ["EFFECT_EVASION_DOWN_HIT"] = true, + ["EFFECT_EVASION_UP"] = true, + ["EFFECT_EVASION_UP_2"] = true, + ["EFFECT_FAKE_OUT"] = true, + ["EFFECT_FALSE_SWIPE"] = true, + ["EFFECT_FLAME_WHEEL"] = true, + ["EFFECT_FLINCH_HIT"] = true, + ["EFFECT_FLY"] = true, + ["EFFECT_FOCUS_ENERGY"] = true, + ["EFFECT_FORCE_SWITCH"] = true, + ["EFFECT_FORESIGHT"] = true, + ["EFFECT_FREEZE_HIT"] = true, + ["EFFECT_FRUSTRATION"] = true, + ["EFFECT_FURY_CUTTER"] = true, + ["EFFECT_FUTURE_SIGHT"] = true, + ["EFFECT_GUST"] = true, + ["EFFECT_HEAL"] = true, + ["EFFECT_HEAL_BELL"] = true, + ["EFFECT_HIDDEN_POWER"] = true, + ["EFFECT_HYPER_BEAM"] = true, + ["EFFECT_JUMP_KICK"] = true, + ["EFFECT_LEECH_HIT"] = true, + ["EFFECT_LEECH_SEED"] = true, + ["EFFECT_LEVEL_DAMAGE"] = true, + ["EFFECT_LIGHT_SCREEN"] = true, + ["EFFECT_LOCK_ON"] = true, + ["EFFECT_MAGNITUDE"] = true, + ["EFFECT_MEAN_LOOK"] = true, + ["EFFECT_METRONOME"] = true, + ["EFFECT_MIMIC"] = true, + ["EFFECT_MIRROR_COAT"] = true, + ["EFFECT_MIRROR_MOVE"] = true, + ["EFFECT_MIST"] = true, + ["EFFECT_MOONLIGHT"] = true, + ["EFFECT_MORNING_SUN"] = true, + ["EFFECT_MULTI_HIT"] = true, + ["EFFECT_NIGHTMARE"] = true, + ["EFFECT_NORMAL_HIT"] = true, + ["EFFECT_OHKO"] = true, + ["EFFECT_PAIN_SPLIT"] = true, + ["EFFECT_PARALYZE"] = true, + ["EFFECT_PARALYZE_HIT"] = true, + ["EFFECT_PAY_DAY"] = true, + ["EFFECT_PERISH_SONG"] = true, + ["EFFECT_POISON"] = true, + ["EFFECT_POISON_HIT"] = true, + ["EFFECT_POISON_MULTI_HIT"] = true, + ["EFFECT_PRESENT"] = true, + ["EFFECT_PRIORITY_HIT"] = true, + ["EFFECT_PROTECT"] = true, + ["EFFECT_PSYCH_UP"] = true, + ["EFFECT_PSYWAVE"] = true, + ["EFFECT_PURSUIT"] = true, + ["EFFECT_RAGE"] = true, + ["EFFECT_RAIN_DANCE"] = true, + ["EFFECT_RAMPAGE"] = true, + ["EFFECT_RAPID_SPIN"] = true, + ["EFFECT_RAZOR_WIND"] = true, + ["EFFECT_RECOIL_HIT"] = true, + ["EFFECT_REFLECT"] = true, + ["EFFECT_RESET_STATS"] = true, + ["EFFECT_RETURN"] = true, + ["EFFECT_REVERSAL"] = true, + ["EFFECT_ROLLOUT"] = true, + ["EFFECT_SACRED_FIRE"] = true, + ["EFFECT_SAFEGUARD"] = true, + ["EFFECT_SANDSTORM"] = true, + ["EFFECT_SELFDESTRUCT"] = true, + ["EFFECT_SKETCH"] = true, + ["EFFECT_SKULL_BASH"] = true, + ["EFFECT_SKY_ATTACK"] = true, + ["EFFECT_SLEEP"] = true, + ["EFFECT_SLEEP_TALK"] = true, + ["EFFECT_SNORE"] = true, + ["EFFECT_SOLARBEAM"] = true, + ["EFFECT_SPEED_DOWN"] = true, + ["EFFECT_SPEED_DOWN_2"] = true, + ["EFFECT_SPEED_DOWN_HIT"] = true, + ["EFFECT_SPEED_UP"] = true, + ["EFFECT_SPEED_UP_2"] = true, + ["EFFECT_SPIKES"] = true, + ["EFFECT_SPITE"] = true, + ["EFFECT_SPLASH"] = true, + ["EFFECT_SP_ATK_DOWN"] = true, + ["EFFECT_SP_ATK_DOWN_2"] = true, + ["EFFECT_SP_ATK_DOWN_HIT"] = true, + ["EFFECT_SP_ATK_UP"] = true, + ["EFFECT_SP_ATK_UP_2"] = true, + ["EFFECT_SP_DEF_DOWN"] = true, + ["EFFECT_SP_DEF_DOWN_2"] = true, + ["EFFECT_SP_DEF_DOWN_HIT"] = true, + ["EFFECT_SP_DEF_UP"] = true, + ["EFFECT_SP_DEF_UP_2"] = true, + ["EFFECT_STATIC_DAMAGE"] = true, + ["EFFECT_STOMP"] = true, + ["EFFECT_SUBSTITUTE"] = true, + ["EFFECT_SUNNY_DAY"] = true, + ["EFFECT_SUPER_FANG"] = true, + ["EFFECT_SWAGGER"] = true, + ["EFFECT_SYNTHESIS"] = true, + ["EFFECT_TELEPORT"] = true, + ["EFFECT_THIEF"] = true, + ["EFFECT_THUNDER"] = true, + ["EFFECT_TOXIC"] = true, + ["EFFECT_TRANSFORM"] = true, + ["EFFECT_TRAP_TARGET"] = true, + ["EFFECT_TRIPLE_KICK"] = true, + ["EFFECT_TRI_ATTACK"] = true, + ["EFFECT_TWISTER"] = true, + ["EFFECT_UNUSED_25"] = true, + ["EFFECT_UNUSED_2B"] = true, + ["EFFECT_UNUSED_4E"] = true, + ["EFFECT_UNUSED_6E"] = true, + ["EFFECT_UNUSED_82"] = true, + ["EFFECT_UNUSED_83"] = true, + ["ENCORE"] = true, + ["ENDEAVOR"] = true, + ["ENDURE"] = true, + ["ERUPTION"] = true, + ["EVASION_DOWN"] = true, + ["EVASION_DOWN1_EFFECT"] = true, + ["EVASION_DOWN2_EFFECT"] = true, + ["EVASION_DOWN_2"] = true, + ["EVASION_DOWN_HIT"] = true, + ["EVASION_UP"] = true, + ["EVASION_UP1_EFFECT"] = true, + ["EVASION_UP2_EFFECT"] = true, + ["EVASION_UP_2"] = true, + ["EXPLODE_EFFECT"] = true, + ["EXPLOSION"] = true, + ["FACADE"] = true, + ["FAKE_OUT"] = true, + ["FALSE_SWIPE"] = true, + ["FLAIL"] = true, + ["FLATTER"] = true, + ["FLINCH_HIT"] = true, + ["FLINCH_MINIMIZE_HIT"] = true, + ["FLINCH_SIDE_EFFECT1"] = true, + ["FLINCH_SIDE_EFFECT2"] = true, + ["FLY"] = true, + ["FLY_EFFECT"] = true, + ["FOCUS_ENERGY"] = true, + ["FOCUS_ENERGY_EFFECT"] = true, + ["FOCUS_PUNCH"] = true, + ["FOLLOW_ME"] = true, + ["FORESIGHT"] = true, + ["FREEZE_HIT"] = true, + ["FREEZE_SIDE_EFFECT1"] = true, + ["FREEZE_SIDE_EFFECT2"] = true, + ["FRUSTRATION"] = true, + ["FURY_CUTTER"] = true, + ["FUTURE_SIGHT"] = true, + ["GRUDGE"] = true, + ["GUST"] = true, + ["HAIL"] = true, + ["HAZE"] = true, + ["HAZE_EFFECT"] = true, + ["HEAL_BELL"] = true, + ["HEAL_EFFECT"] = true, + ["HELPING_HAND"] = true, + ["HIDDEN_POWER"] = true, + ["HIGH_CRITICAL"] = true, + ["HIT"] = true, + ["HYPER_BEAM_EFFECT"] = true, + ["IMPRISON"] = true, + ["INGRAIN"] = true, + ["JUMP_KICK_EFFECT"] = true, + ["KNOCK_OFF"] = true, + ["LEECH_SEED"] = true, + ["LEECH_SEED_EFFECT"] = true, + ["LEVEL_DAMAGE"] = true, + ["LIGHT_SCREEN"] = true, + ["LIGHT_SCREEN_EFFECT"] = true, + ["LOCK_ON"] = true, + ["LOW_KICK"] = true, + ["MAGIC_COAT"] = true, + ["MAGNITUDE"] = true, + ["MEAN_LOOK"] = true, + ["MEMENTO"] = true, + ["METRONOME"] = true, + ["METRONOME_EFFECT"] = true, + ["MIMIC"] = true, + ["MIMIC_EFFECT"] = true, + ["MINIMIZE"] = true, + ["MIRROR_COAT"] = true, + ["MIRROR_MOVE"] = true, + ["MIRROR_MOVE_EFFECT"] = true, + ["MIST"] = true, + ["MIST_EFFECT"] = true, + ["MOONLIGHT"] = true, + ["MORNING_SUN"] = true, + ["MUD_SPORT"] = true, + ["MULTI_HIT"] = true, + ["NATURE_POWER"] = true, + ["NIGHTMARE"] = true, + ["NO_ADDITIONAL_EFFECT"] = true, + ["OHKO"] = true, + ["OHKO_EFFECT"] = true, + ["OVERHEAT"] = true, + ["PAIN_SPLIT"] = true, + ["PARALYZE"] = true, + ["PARALYZE_EFFECT"] = true, + ["PARALYZE_HIT"] = true, + ["PARALYZE_SIDE_EFFECT1"] = true, + ["PARALYZE_SIDE_EFFECT2"] = true, + ["PAY_DAY"] = true, + ["PAY_DAY_EFFECT"] = true, + ["PERISH_SONG"] = true, + ["POISON"] = true, + ["POISON_EFFECT"] = true, + ["POISON_FANG"] = true, + ["POISON_HIT"] = true, + ["POISON_SIDE_EFFECT1"] = true, + ["POISON_SIDE_EFFECT2"] = true, + ["POISON_TAIL"] = true, + ["PRESENT"] = true, + ["PROTECT"] = true, + ["PSYCH_UP"] = true, + ["PSYWAVE"] = true, + ["PURSUIT"] = true, + ["QUICK_ATTACK"] = true, + ["RAGE"] = true, + ["RAGE_EFFECT"] = true, + ["RAIN_DANCE"] = true, + ["RAMPAGE"] = true, + ["RAPID_SPIN"] = true, + ["RAZOR_WIND"] = true, + ["RECHARGE"] = true, + ["RECOIL"] = true, + ["RECOIL_25"] = true, + ["RECOIL_EFFECT"] = true, + ["RECOIL_IF_MISS"] = true, + ["RECOVER"] = true, + ["RECYCLE"] = true, + ["REFLECT"] = true, + ["REFLECT_EFFECT"] = true, + ["REFRESH"] = true, + ["REST"] = true, + ["RESTORE_HP"] = true, + ["RETURN"] = true, + ["REVENGE"] = true, + ["ROAR"] = true, + ["ROLE_PLAY"] = true, + ["ROLLOUT"] = true, + ["SAFEGUARD"] = true, + ["SANDSTORM"] = true, + ["SECONDARY"] = true, + ["SECRET_POWER"] = true, + ["SECRET_POWER_BY_TERRAIN"] = true, + ["SEMI_INVULNERABLE"] = true, + ["SKETCH"] = true, + ["SKILL_SWAP"] = true, + ["SKULL_BASH"] = true, + ["SKY_ATTACK"] = true, + ["SKY_UPPERCUT"] = true, + ["SLEEP"] = true, + ["SLEEP_EFFECT"] = true, + ["SLEEP_TALK"] = true, + ["SMELLINGSALT"] = true, + ["SNATCH"] = true, + ["SNORE"] = true, + ["SOFTBOILED"] = true, + ["SOLAR_BEAM"] = true, + ["SONICBOOM"] = true, + ["SPECIAL_ATTACK_DOWN"] = true, + ["SPECIAL_ATTACK_DOWN_2"] = true, + ["SPECIAL_ATTACK_DOWN_HIT"] = true, + ["SPECIAL_ATTACK_UP"] = true, + ["SPECIAL_ATTACK_UP_2"] = true, + ["SPECIAL_DAMAGE_EFFECT"] = true, + ["SPECIAL_DEFENSE_DOWN"] = true, + ["SPECIAL_DEFENSE_DOWN_2"] = true, + ["SPECIAL_DEFENSE_DOWN_HIT"] = true, + ["SPECIAL_DEFENSE_UP"] = true, + ["SPECIAL_DEFENSE_UP_2"] = true, + ["SPECIAL_DOWN1_EFFECT"] = true, + ["SPECIAL_DOWN2_EFFECT"] = true, + ["SPECIAL_DOWN_SIDE_EFFECT"] = true, + ["SPECIAL_UP1_EFFECT"] = true, + ["SPECIAL_UP2_EFFECT"] = true, + ["SPEED_DOWN"] = true, + ["SPEED_DOWN1_EFFECT"] = true, + ["SPEED_DOWN2_EFFECT"] = true, + ["SPEED_DOWN_2"] = true, + ["SPEED_DOWN_HIT"] = true, + ["SPEED_DOWN_SIDE_EFFECT"] = true, + ["SPEED_UP"] = true, + ["SPEED_UP1_EFFECT"] = true, + ["SPEED_UP2_EFFECT"] = true, + ["SPEED_UP_2"] = true, + ["SPIKES"] = true, + ["SPITE"] = true, + ["SPIT_UP"] = true, + ["SPLASH"] = true, + ["SPLASH_EFFECT"] = true, + ["STATUS_SETUP"] = true, + ["STAT_CHANGES"] = true, + ["STOCKPILE"] = true, + ["SUBSTITUTE"] = true, + ["SUBSTITUTE_EFFECT"] = true, + ["SUNNY_DAY"] = true, + ["SUPERPOWER"] = true, + ["SUPER_FANG"] = true, + ["SUPER_FANG_EFFECT"] = true, + ["SWAGGER"] = true, + ["SWALLOW"] = true, + ["SWIFT_EFFECT"] = true, + ["SWITCH_AND_TELEPORT_EFFECT"] = true, + ["SYNTHESIS"] = true, + ["TAUNT"] = true, + ["TEETER_DANCE"] = true, + ["TELEPORT"] = true, + ["THAW_HIT"] = true, + ["THIEF"] = true, + ["THRASH"] = true, + ["THRASH_PETAL_DANCE_EFFECT"] = true, + ["THUNDER"] = true, + ["TICKLE"] = true, + ["TORMENT"] = true, + ["TOXIC"] = true, + ["TRANSFORM"] = true, + ["TRANSFORM_EFFECT"] = true, + ["TRAP"] = true, + ["TRAPPING_EFFECT"] = true, + ["TRICK"] = true, + ["TRIPLE_KICK"] = true, + ["TRI_ATTACK"] = true, + ["TWINEEDLE"] = true, + ["TWINEEDLE_EFFECT"] = true, + ["TWISTER"] = true, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = true, + ["UPROAR"] = true, + ["VITAL_THROW"] = true, + ["WATER_SPORT"] = true, + ["WEATHER_BALL"] = true, + ["WILL_O_WISP"] = true, + ["WISH"] = true, + ["YAWN"] = true, + }, + ["variants"] = { + ["ACCURACY_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=23}, + ["ATTACK_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=18}, + ["ATTACK_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=58}, + ["ATTACK_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=68}, + ["ATTACK_TWICE_EFFECT"] = {["chance"]=0,["semantic"]=44}, + ["ATTACK_UP1_EFFECT"] = {["chance"]=0,["semantic"]=10}, + ["ATTACK_UP2_EFFECT"] = {["chance"]=0,["semantic"]=50}, + ["BIDE_EFFECT"] = {["chance"]=0,["semantic"]=26}, + ["BURN_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=4}, + ["BURN_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=4}, + ["CHARGE_EFFECT"] = {["chance"]=0,["semantic"]=39}, + ["CONFUSION_EFFECT"] = {["chance"]=0,["semantic"]=49}, + ["CONFUSION_SIDE_EFFECT"] = {["chance"]=10,["semantic"]=76}, + ["CONVERSION_EFFECT"] = {["chance"]=0,["semantic"]=30}, + ["DEFENSE_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=19}, + ["DEFENSE_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=59}, + ["DEFENSE_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=69}, + ["DEFENSE_UP1_EFFECT"] = {["chance"]=0,["semantic"]=11}, + ["DEFENSE_UP2_EFFECT"] = {["chance"]=0,["semantic"]=51}, + ["DISABLE_EFFECT"] = {["chance"]=0,["semantic"]=86}, + ["DRAIN_HP_EFFECT"] = {["chance"]=0,["semantic"]=3}, + ["DREAM_EATER_EFFECT"] = {["chance"]=0,["semantic"]=8}, + ["EVASION_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=24}, + ["EVASION_UP1_EFFECT"] = {["chance"]=0,["semantic"]=16}, + ["EXPLODE_EFFECT"] = {["chance"]=0,["semantic"]=7}, + ["FLINCH_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=31}, + ["FLINCH_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=31}, + ["FLY_EFFECT"] = {["chance"]=0,["semantic"]=155}, + ["FOCUS_ENERGY_EFFECT"] = {["chance"]=0,["semantic"]=47}, + ["FREEZE_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=5}, + ["FREEZE_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=5}, + ["HAZE_EFFECT"] = {["chance"]=0,["semantic"]=25}, + ["HEAL_EFFECT"] = {["chance"]=0,["semantic"]=32}, + ["HYPER_BEAM_EFFECT"] = {["chance"]=0,["semantic"]=80}, + ["JUMP_KICK_EFFECT"] = {["chance"]=0,["semantic"]=45}, + ["LEECH_SEED_EFFECT"] = {["chance"]=0,["semantic"]=84}, + ["LIGHT_SCREEN_EFFECT"] = {["chance"]=0,["semantic"]=35}, + ["METRONOME_EFFECT"] = {["chance"]=0,["semantic"]=83}, + ["MIMIC_EFFECT"] = {["chance"]=0,["semantic"]=82}, + ["MIRROR_MOVE_EFFECT"] = {["chance"]=0,["semantic"]=9}, + ["MIST_EFFECT"] = {["chance"]=0,["semantic"]=46}, + ["NO_ADDITIONAL_EFFECT"] = {["chance"]=0,["semantic"]=0}, + ["OHKO_EFFECT"] = {["chance"]=0,["semantic"]=38}, + ["PARALYZE_EFFECT"] = {["chance"]=0,["semantic"]=67}, + ["PARALYZE_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=6}, + ["PARALYZE_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=6}, + ["PAY_DAY_EFFECT"] = {["chance"]=0,["semantic"]=34}, + ["POISON_EFFECT"] = {["chance"]=0,["semantic"]=66}, + ["POISON_SIDE_EFFECT1"] = {["chance"]=20,["semantic"]=2}, + ["POISON_SIDE_EFFECT2"] = {["chance"]=40,["semantic"]=2}, + ["RAGE_EFFECT"] = {["chance"]=0,["semantic"]=81}, + ["RECOIL_EFFECT"] = {["chance"]=0,["semantic"]=48}, + ["REFLECT_EFFECT"] = {["chance"]=0,["semantic"]=65}, + ["SLEEP_EFFECT"] = {["chance"]=0,["semantic"]=1}, + ["SPECIAL_DAMAGE_EFFECT"] = {["chance"]=0,["semantic"]=87}, + ["SPECIAL_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=62}, + ["SPECIAL_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=72}, + ["SPECIAL_UP1_EFFECT"] = {["chance"]=0,["semantic"]=13}, + ["SPECIAL_UP2_EFFECT"] = {["chance"]=0,["semantic"]=54}, + ["SPEED_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=20}, + ["SPEED_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=60}, + ["SPEED_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=70}, + ["SPEED_UP2_EFFECT"] = {["chance"]=0,["semantic"]=52}, + ["SPLASH_EFFECT"] = {["chance"]=0,["semantic"]=85}, + ["SUBSTITUTE_EFFECT"] = {["chance"]=0,["semantic"]=79}, + ["SUPER_FANG_EFFECT"] = {["chance"]=0,["semantic"]=40}, + ["SWIFT_EFFECT"] = {["chance"]=0,["semantic"]=17}, + ["SWITCH_AND_TELEPORT_EFFECT"] = {["chance"]=0,["semantic"]=28}, + ["THRASH_PETAL_DANCE_EFFECT"] = {["chance"]=0,["semantic"]=27}, + ["TRANSFORM_EFFECT"] = {["chance"]=0,["semantic"]=57}, + ["TRAPPING_EFFECT"] = {["chance"]=0,["semantic"]=42}, + ["TWINEEDLE_EFFECT"] = {["chance"]=20,["semantic"]=77}, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = {["chance"]=0,["semantic"]=29}, + }, +} diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua index dde1870a8..93a258afe 100644 --- a/src/mods/UnifiedContent.lua +++ b/src/mods/UnifiedContent.lua @@ -4,6 +4,7 @@ local Merge = require("src.mods.Merge") local Schemas = require("src.mods.Schemas") local Shapes = require("src.mods.UnifiedSchemas") local Ids = require("src.mods.UnifiedIds") +local Effects = require("src.mods.UnifiedEffects") local U = {} local copy, DELETE = Merge.deepCopy, Merge.DELETE local function unsupported(reason) error({ unsupported = reason }, 0) end @@ -160,7 +161,8 @@ readRecord = function(ctx, kind, id, raw) elseif kind == "moves" then out.type = typeRead(raw.type) out.category = raw.category or raw.class - if type(raw.effect) ~= "string" then out.effect = nil end + out.effect = Effects.read(raw.effect, ctx.gen, raw) + out.secondaryChance = Effects.chance(raw, ctx.gen) elseif kind == "items" then if type(raw.effect) ~= "string" then out.effect = nil end if raw.machine then out.machine.move = ctx.ids:read("moves", raw.machine.move) end @@ -272,9 +274,30 @@ local function writeRecord(ctx, kind, id, value, old, mode) out.type = typeWrite(ctx, value.type) if old and old.typeId and out.type ~= old.type then unsupported("changing a DS type requires native type metadata") end end + local probability = value.secondaryChance if value.effect and value.effect ~= DELETE then - reference(ctx, "move_effects", value.effect) - if ctx.gen > 1 and (not old or value.effect ~= old.effect) then unsupported("changing move effects requires a native effect adapter") end + local plan, changed = Effects.write(value.effect, ctx.gen, old, function(effect) + return ctx.loader.content.move_effects:get(effect) ~= nil + end) + if not plan then unsupported(changed) end + out.effect = plan.effect + if changed then + out._api3EffectId = plan._api3EffectId + if ctx.gen == 3 then out.gen3Effect = plan.gen3Effect or DELETE end + if ctx.gen > 1 and probability == nil then probability = plan.probability or DELETE end + end + end + out.secondaryChance = nil + if probability ~= nil then + if ctx.gen == 1 or ctx.gen > 3 then unsupported("secondaryChance requires a Gen 2/3 native adapter") end + if old and probability ~= DELETE and probability == Effects.chance(old, ctx.gen) + and (probability ~= 0 or old._api3EffectChance or value.native ~= nil) then + -- Detached records preserve the exact original native chance byte. + else + out.effectChance = probability == DELETE and DELETE or math.floor(probability * 256 / 100 + 0.5) + out._api3EffectChance = probability == DELETE and DELETE or true + if ctx.gen == 3 then out.secondaryChance = probability end + end end if value.category ~= nil and old and old.class ~= nil then check(value.category ~= DELETE, "native move class cannot be deleted") diff --git a/src/mods/UnifiedEffects.lua b/src/mods/UnifiedEffects.lua new file mode 100644 index 000000000..2083f29a1 --- /dev/null +++ b/src/mods/UnifiedEffects.lua @@ -0,0 +1,135 @@ +-- Canonical effect IDs over this host's shared native battle handlers. +local C = require("src.mods.MoveEffectContract") +local E = {} +local numbers, nativeNames, variants = {}, {}, {} +for number in pairs(C.native3) do + numbers[#numbers + 1] = tonumber(number) +end +table.sort(numbers) +for _, number in ipairs(numbers) do + for _, name in ipairs(C.native3[tostring(number)]) do + if nativeNames[name] == nil then + nativeNames[name] = number + end + end +end +for id in pairs(C.variants) do + variants[#variants + 1] = id +end +table.sort(variants) +local function native(row, gen, chance) + if gen == 1 then + return row.native + end + local names = C.native3[tostring(row.semantic)] + if not names then + return nil + end + return names[2] and (chance or row.chance or 0) > 10 and names[2] or names[1] +end +local function opaque(value, gen) + return "native:" .. gen .. ":" .. tostring(value) +end +function E.chance(raw, gen) + if gen ~= 2 and gen ~= 3 then + return nil + end + if type(raw.effectChance) == "number" then + return math.max(0, math.min(100, math.floor(raw.effectChance * 100 / 256 + 0.5))) + end + return gen == 3 and raw.secondaryChance or nil +end +function E.read(value, gen, raw) + if value == nil then + return nil + end + if gen == 1 or gen > 3 then + return type(value) == "string" and value or nil + end + raw = raw or {} + local probability = E.chance(raw, gen) + local authored = raw._api3EffectId + local binding = type(authored) == "string" and C.bindings[authored] + if binding and binding[tostring(gen)] then + local row = binding[tostring(gen)] + for _, name in ipairs(C.native3[tostring(row.semantic)] or {}) do + if name == value then + return authored + end + end + end + if authored == value and not C.reserved[authored] then + return authored + end + local semantic = nativeNames[value] + if gen == 3 and type(raw.gen3Effect) == "number" then + local names = C.native3[tostring(raw.gen3Effect)] + for _, name in ipairs(names or {}) do + if name == value then + semantic = raw.gen3Effect + end + end + elseif value == "HEAL_EFFECT" and raw.id == "REST" then + semantic = 37 + end + if not semantic then + return opaque(value, gen) + end + for _, id in ipairs(variants) do + local row = C.variants[id] + if row.semantic == semantic and row.chance == probability and C.bindings[id][tostring(gen)] then + return id + end + end + local id = C.read3[tostring(semantic)] + if id and C.bindings[id] and C.bindings[id][tostring(gen)] then + return id + end + return opaque(value, gen) +end +function E.write(id, gen, old, registered) + if type(id) ~= "string" or id == "" then + return nil, "move effect must be a string id" + end + id = ({ HIT = "NO_ADDITIONAL_EFFECT", SLEEP = "SLEEP_EFFECT" })[id] or id + if old and (id == E.read(old.effect, gen, old) or id == opaque(old.effect, gen)) then + return { effect = old.effect }, false + end + if gen > 3 then + return nil, "changing move effects has no native adapter for this generation" + end + if id:match("^native:") then + return nil, "native effect token requires its original record" + end + local binding = C.bindings[id] + local row = binding and binding[tostring(gen)] + if not row then + if binding or C.reserved[id] or not registered(id) then + return nil, "effect has no native handler: " .. id + end + return { effect = id, _api3EffectId = gen > 1 and id or nil }, true + end + if + gen > 1 + and ( + (row.semantic == 37 and (not old or old.id ~= "REST")) + or row.semantic == 32 and old and old.id == "REST" + or row.semantic == 130 and (not old or old.id ~= "SONICBOOM") + or row.semantic == 41 and old and old.id == "SONICBOOM" + ) + then + return nil, "native effect depends on move identity: " .. id + end + local target = native(row, gen) + if not target or not registered(target) then + return nil, "effect has no native handler: " .. id + end + local out = { effect = target } + if gen > 1 then + out._api3EffectId = id + out.probability = row.chance + out.gen3Effect = gen == 3 and row.semantic or nil + end + return out, true +end +return E diff --git a/src/mods/UnifiedSchemas.lua b/src/mods/UnifiedSchemas.lua index 10d2f6bb2..aa9a91cbe 100644 --- a/src/mods/UnifiedSchemas.lua +++ b/src/mods/UnifiedSchemas.lua @@ -34,7 +34,7 @@ local U = { } }, moves = { fields = { id = id, name = f.str, type = id, power = f.int(0, 255), accuracy = f.int(0, 100), - pp = f.int(0, 64), effect = f.opt(id), + pp = f.int(0, 64), effect = f.opt(id), secondaryChance = f.opt(f.int(0, 100)), category = f.opt(f.enum { "physical", "special", "status" }), priority = f.opt(f.int(-7, 7)), description = f.opt(f.str), } }, diff --git a/tests/api3_contract/effects.lua b/tests/api3_contract/effects.lua new file mode 100644 index 000000000..15430e813 --- /dev/null +++ b/tests/api3_contract/effects.lua @@ -0,0 +1,13 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/effects%.lua$") + return dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "effects") +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local report = f.exports.report() + assert(report.initial == "NO_ADDITIONAL_EFFECT" and report.final == "SLEEP_EFFECT") + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/mods/effects/main.lua b/tests/api3_contract/mods/effects/main.lua new file mode 100644 index 000000000..a01378e23 --- /dev/null +++ b/tests/api3_contract/mods/effects/main.lua @@ -0,0 +1,33 @@ +local mod = ... +local moves = mod.content.moves +local report = { initial = assert(moves:get("POUND")).effect, effects = {} } +for _, id in ipairs({ + "SLEEP_EFFECT", + "BURN_SIDE_EFFECT1", + "BURN_SIDE_EFFECT2", + "SPECIAL_UP1_EFFECT", + "SPECIAL_DOWN_SIDE_EFFECT", + "NO_ADDITIONAL_EFFECT", +}) do + assert(moves:patch("POUND", { effect = id })) + local row = assert(moves:get("POUND")) + assert(row.effect == id) + report.effects[#report.effects + 1] = row.effect + if mod.generation > 1 then + assert(moves:patch("POUND", { secondaryChance = 25 })) + assert(moves:get("POUND").secondaryChance == 25) + report.chance = 25 + end + local detached = assert(moves:get("POUND")) + assert(moves:override("POUND", detached)) + assert(moves:get("POUND").effect == id) +end +local value, why = moves:patch("POUND", { effect = "native:1:not_original" }) +assert(value == nil and type(why) == "string") +assert(moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT") +assert(moves:patch("POUND", { effect = "SLEEP_EFFECT" })) +report.final = moves:get("POUND").effect +report.preflight = true +mod.exports.report = function() + return report +end diff --git a/tests/api3_contract/mods/effects/manifest.json b/tests/api3_contract/mods/effects/manifest.json new file mode 100644 index 000000000..48e0d38a3 --- /dev/null +++ b/tests/api3_contract/mods/effects/manifest.json @@ -0,0 +1 @@ +{"id":"effects","name":"Portable move effects","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 84ff30889..d6f8b8485 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -28,11 +28,14 @@ assert( or mode == "script_audio" or mode == "script_dialogue_audio" or mode == "script_fields" + or mode == "effects" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_fields" then +if mode == "effects" then + dofile(codeRoot .. "/effects.lua").run(root, backend, version) +elseif mode == "script_fields" then dofile(codeRoot .. "/script_fields.lua").run(root, backend, version) elseif mode == "script_dialogue_audio" then dofile(codeRoot .. "/script_dialogue_audio.lua").run(root, backend, version) diff --git a/tests/engine/unified_move_effects.lua b/tests/engine/unified_move_effects.lua new file mode 100644 index 000000000..8d2c0c6af --- /dev/null +++ b/tests/engine/unified_move_effects.lua @@ -0,0 +1,208 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local E = require("src.mods.UnifiedEffects") +local C = require("src.mods.MoveEffectContract") +local Native = require("src.battle.MoveEffects").RECORDS +local function registered(id) + return Native[id] ~= nil +end +local accepted = 0 +for id, bindings in pairs(C.bindings) do + for g, row in pairs(bindings) do + local gen = tonumber(g) + local old = { id = row.semantic == 37 and "REST" or row.semantic == 130 and "SONICBOOM" or "POUND" } + local plan, why = E.write(id, gen, old, registered) + if plan then + accepted = accepted + 1 + T.check(registered(plan.effect), "accepted effect has actual handler " .. g .. ":" .. id) + if gen > 1 then + plan.effectChance = plan.probability and math.floor(plan.probability * 256 / 100 + 0.5) + end + T.eq( + E.read(plan.effect, gen, plan), + ({ HIT = "NO_ADDITIONAL_EFFECT", SLEEP = "SLEEP_EFFECT" })[id] or id, + "authored canonical effect read " .. g .. ":" .. id + ) + else + T.check(type(why) == "string", "unrepresentable mapping has reason") + end + end +end +T.check(accepted > 300, "reference matrix exercises hundreds of native bindings") +local Contract = require("tests.api3_contract.effects") +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared effect contract: " .. tostring(why)) +end +for gen = 1, 3 do + local custom = assert(E.write("mod:custom", gen, nil, function(id) + return id == "mod:custom" + end)) + T.eq(custom.effect, "mod:custom", "registered custom native effect selected") + T.eq(E.read(custom.effect, gen, custom), "mod:custom", "custom canonical identity") + T.eq(E.write("unknown", gen, nil, registered), nil, "unknown handler refused") + T.eq(E.write("native:" .. gen .. ":made_up", gen, nil, registered), nil, "forged opaque effect refused") + T.eq( + E.write("native:" .. gen .. ":unhandled", gen, { effect = "unhandled" }, registered).effect, + "unhandled", + "original opaque identity retained" + ) +end +for _, id in ipairs({ + "FOLLOW_ME", + "HELPING_HAND", + "MAGIC_COAT", + "SNATCH", + "IMPRISON", + "ASSIST", + "NATURE_POWER", + "SECRET_POWER", +}) do + T.eq(E.write(id, 3, nil, registered), nil, "missing native Gen3 handler refused " .. id) +end +for _, v in ipairs({ "gold", "firered" }) do + Version.set(v) + local gen = Version.generation() + local plan = assert(E.write("SPECIAL_UP1_EFFECT", gen, { id = "POUND" }, registered)) + local user = { name = "TEST", isPlayer = true, mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + local target = { name = "TARGET", mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + Native[plan.effect].run({ battle = { data = {} }, user = user, target = target, move = plan }) + T.eq(user.stages.spatk, 1, "actual handler raises Special Attack") + T.eq(user.stages.special, nil, "actual handler avoids Gen1 Special stage") + T.eq(user.stages.spdef, nil, "actual handler does not raise Special Defense") + plan = assert(E.write("SPECIAL_DOWN_SIDE_EFFECT", gen, { id = "POUND" }, registered)) + plan.effectChance = 85 + Native[plan.effect].run({ battle = { + data = {}, + rng = function() + return 0 + end, + }, user = user, target = target, move = plan }) + T.eq(target.stages.spdef, -1, "actual handler lowers Special Defense") + plan = assert(E.write("BURN_SIDE_EFFECT1", gen, { id = "POUND" }, registered)) + plan.effectChance = math.floor(plan.probability * 256 / 100 + 0.5) + plan.id, plan.type = "POUND", "FIRE" + local battle = { + data = {}, + rng = function() + return 25 + end, + } + Native[plan.effect].run({ battle = battle, user = user, target = target, move = plan }) + T.eq(target.mon.status, "BRN", "native burn lands below converted threshold") + target.mon.status = nil + battle.rng = function() + return 26 + end + Native[plan.effect].run({ battle = battle, user = user, target = target, move = plan }) + T.eq(target.mon.status, nil, "native burn misses at converted threshold") +end +for gen = 2, 3 do + local old = { id = "POUND", effect = "BURN_SIDE_EFFECT1", effectChance = 27 } + local public = E.read(old.effect, gen, old) + local plan, changed = E.write(public, gen, old, registered) + T.eq(plan.effect, old.effect, "original raw effect roundtrip") + T.eq(changed, false, "roundtrip does not rewrite original odds") + T.eq(E.chance(old, gen), 11, "native fraction projected to whole percent") + T.eq( + E.write("NO_ADDITIONAL_EFFECT", 4, old, registered), + nil, + "later generation changing effect still unavailable" + ) + T.eq(E.read(999, 4, {}), nil, "DS opaque selector retains diagnostic-only behavior") +end +-- Registry operations use the adapter before journaling; refusals are atomic. +local function load(gen, entry, odds) + Version.set(gen == 1 and "red" or gen == 2 and "gold" or "firered") + local data = require("tests.api3_contract.run").dataFor("gen2", gen) + local loader + local fs = { + read = function(path) + if path:match("manifest.json$") then + return '{"id":"probe","name":"Probe","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}' + end + end, + getInfo = function(path) + if path:match("^mods/probe/.+%.%w+$") then + return { type = "file" } + end + if path == "mods" or path == "mods/probe" then + return { type = "directory" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { "probe" } or {} + end, + load = function() + return function(api) + entry(api, loader, data) + end + end, + } + data.moves.POUND.effect = "BURN_SIDE_EFFECT1" + data.moves.POUND.effectChance = odds or 27 + if gen == 3 then + data.moves.POUND.gen3Effect = 4 + data.moves.POUND.secondaryChance = 11 + end + loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = Version.get() }) + loader._game = function() + error("content must not bootstrap Game") + end + loader:load(data) + T.eq(#loader.errors, 0, "public effect mod loads") + return data +end +for gen = 2, 3 do + local data = load(gen, function(mod, loader) + local moves = mod.content.moves + local row = assert(moves:get("POUND")) + T.eq(row.secondaryChance, 11, "public native odds projection") + assert(moves:override("POUND", row)) + T.eq(moves:get("POUND").native.effectChance, 27, "detached override keeps exact native odds") + local operations = #loader.content.moves.ops.POUND + T.eq(moves:patch("POUND", { effect = "SNATCH" }), nil, "unimplemented effect operation refused") + T.eq(#loader.content.moves.ops.POUND, operations, "failed mapping adds no operation") + T.check(not pcall(function() + moves:patch("POUND", { secondaryChance = 101 }) + end), "invalid percent rejected") + T.eq(#loader.content.moves.ops.POUND, operations, "invalid chance adds no operation") + assert(moves:patch("POUND", { effect = "FLINCH_SIDE_EFFECT2", secondaryChance = 5 })) + T.eq(moves:get("POUND").effect, "FLINCH_SIDE_EFFECT2", "authored alias persists after odds edit") + assert(moves:patch("POUND", { effect = "BURN_SIDE_EFFECT1", secondaryChance = 0 })) + T.eq(moves:get("POUND").secondaryChance, 0, "zero probability retained") + end) + local target = { name = "TARGET", mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + Native[data.moves.POUND.effect].run({ + battle = { + data = {}, + rng = function() + return 0 + end, + }, + user = {}, + target = target, + move = data.moves.POUND, + }) + T.eq(target.mon.status, nil, "actual handler respects explicit zero instead of native default") +end +for gen = 2, 3 do + local data = load(gen, function(mod) + local moves = mod.content.moves + assert(moves:override("POUND", assert(moves:get("POUND")))) + T.eq( + moves:get("POUND").native._api3EffectChance, + nil, + "untouched zero-byte roundtrip preserves native fallback" + ) + assert(moves:patch("POUND", { secondaryChance = 0 })) + T.eq(moves:get("POUND").native._api3EffectChance, true, "explicit zero patch arms native zero semantics") + end, 0) +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5] +love = saved[6] +T.finish() diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 66b2755d9..f2229802d 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio", "script_dialogue_audio", "script_fields"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio", "script_dialogue_audio", "script_fields", "effects"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures diff --git a/tools/move-effect-contract/export_reference.lua b/tools/move-effect-contract/export_reference.lua new file mode 100644 index 000000000..47f75c4a6 --- /dev/null +++ b/tools/move-effect-contract/export_reference.lua @@ -0,0 +1,44 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local E = require("src.mods.UnifiedEffects") +local M1 = require("src.battle.g3u.EffectMap1") +local M2 = require("src.battle.g3u.EffectMap2") +local N = require("src.core.game3.battle.effect_ids") +local ids = {} +for _, t in ipairs({ M1.EFFECTS, M1.UNSUPPORTED, M2.EFFECTS, M2.UNSUPPORTED, N }) do + for k in pairs(t) do + ids[k] = true + end +end +local out = { bindings = {}, read3 = {}, read2 = {}, variants = {}, reserved = {} } +for id in pairs(ids) do + out.reserved[id] = true +end +for id, row in pairs(M1.EFFECTS) do + out.variants[id] = { semantic = row.effect, chance = row.chance } +end +for id in pairs(ids) do + local row = {} + for gen = 1, 3 do + local native, chance = E.write(id, gen) + if native then + local semantic = gen == 3 and native + or gen == 2 and M2.EFFECTS[native] and M2.EFFECTS[native].effect + or nil + if gen == 2 and native == "EFFECT_BURN" then + semantic = N.WILL_O_WISP + end + row[tostring(gen)] = + { native = native, chance = type(chance) == "number" and chance or nil, semantic = semantic } + end + end + if next(row) then + out.bindings[id] = row + end +end +for i = 0, 255 do + out.read3[tostring(i)] = E.read(i, 3, {}) +end +for id in pairs(M2.EFFECTS) do + out.read2[id] = E.read(id, 2, {}) +end +print(require("src.link.Json").encode(out)) diff --git a/tools/move-effect-contract/generate.py b/tools/move-effect-contract/generate.py new file mode 100644 index 000000000..cb53f7e34 --- /dev/null +++ b/tools/move-effect-contract/generate.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Project the pinned reference effect contract onto host native handler names.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess + +PIN = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +ROOT = Path(__file__).resolve().parents[2] + + +def lua(v): + if v is None: + return "nil" + if v is True: + return "true" + if v is False: + return "false" + if isinstance(v, (int, float)): + return str(v) + if isinstance(v, str): + return json.dumps(v, ensure_ascii=False) + if isinstance(v, list): + return "{" + ",".join(lua(x) for x in v) + "}" + return "{" + ",".join("[" + lua(k) + "]=" + lua(x) for k, x in sorted(v.items())) + "}" + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--lua", required=True) + p.add_argument("--check", action="store_true") + a = p.parse_args() + ref = a.gen1.resolve() + def git(*args): + return subprocess.check_output(["git", "-C", str(ref), *args], text=True).strip() + if git("rev-parse", "HEAD") != PIN or git("status", "--porcelain", "--untracked-files=normal"): + p.error("reference must be clean at " + PIN) + raw = subprocess.check_output([a.lua, str(Path(__file__).with_name("export_reference.lua"))], cwd=ref) + data = json.loads(raw) + src = ROOT / "src/import/RomExtractorGen3.lua" + block = src.read_text().split("local GEN3_MOVE_EFFECTS = {", 1)[1].split("\n}", 1)[0] + data["native3"] = {n: re.findall(r'"([A-Z0-9_]+)"', row) + for n, row in re.findall(r'\[(\d+)\]\s*=\s*\{([^}]+)\}', block)} + data.pop("read2") + files = ["src/mods/UnifiedEffects.lua", "src/battle/g3u/EffectMap1.lua", + "src/battle/g3u/EffectMap2.lua", "src/core/game3/battle/effect_ids.lua"] + data["reference"] = {"commit": PIN, "sha256": { + f: hashlib.sha256((ref / f).read_bytes()).hexdigest() for f in files}} + data["hostMapSha256"] = hashlib.sha256(block.encode()).hexdigest() + lines = ["-- Generated by tools/move-effect-contract/generate.py; do not hand-edit.", "return {"] + for group, rows in sorted(data.items()): + lines.append(" [" + lua(group) + "] = {") + for key, value in sorted(rows.items() if isinstance(rows, dict) else []): + lines.append(" [" + lua(key) + "] = " + lua(value) + ",") + if not isinstance(rows, dict): + lines[-1] = " [" + lua(group) + "] = " + lua(rows) + "," + else: + lines.append(" },") + lines.append("}") + text = "\n".join(lines) + "\n" + target = ROOT / "src/mods/MoveEffectContract.lua" + if a.check: + if target.read_text() != text: + raise SystemExit("effect contract differs; regenerate") + print("PASS pinned effect bindings and host native-name map") + else: + target.write_text(text) + print("Wrote", target) + + +if __name__ == "__main__": + main() From 72f4fb2acb8bb84678369ddb0ce2807ef512c69f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:39:05 +0200 Subject: [PATCH 32/71] 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 33/71] 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 34/71] 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 35/71] 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 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 36/71] 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 37/71] 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 38/71] 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 39/71] 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 40/71] 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 41/71] 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 42/71] 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 43/71] 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 44/71] 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 45/71] 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 46/71] 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 47/71] 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 48/71] 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 8025fd12b9b79dfcd0f0378856500caff742be47 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:58:28 +0100 Subject: [PATCH 49/71] Restore native touch hotbar and real guarded speed action --- src/core/Game.lua | 58 +++++++++-- src/core/GenOptions.lua | 1 + src/core/SaveData.lua | 1 + src/core/TouchControls.lua | 159 ++++++++++++++++++++++++++++- src/ui/OptionsMenu.lua | 7 ++ src/ui/TouchControlsEditor.lua | 4 +- tests/engine/touch_hotbar_1592.lua | 147 ++++++++++++++++++++++++++ 7 files changed, 365 insertions(+), 12 deletions(-) create mode 100644 tests/engine/touch_hotbar_1592.lua diff --git a/src/core/Game.lua b/src/core/Game.lua index 50f7370b7..408563182 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -315,6 +315,21 @@ end -- Skin actions use the same native speed/title/options owners as keyboard input. Game.SKIN_FAST_FORWARD = 4 +function Game:_cycleSpeed(dir) + if not (self.save and self.save.options) or self:speedLocked() then return false end + local ow = self.overworld + local top = self.stack and self.stack:top() + if ow and (ow.transitioning or (top == ow and ( + (ow.runner and ow.runner.isRunning and ow.runner:isRunning()) + or (ow.scriptMoves and #ow.scriptMoves > 0) or ow.engaging or ow.emote))) then + return false + end + self.save.options.speed = require('src.core.GameSpeed').cycle(self.save.options.speed, dir or 1) + self.speedOverride = nil + self:writeOptions() + return true +end + function Game:touchSkinHotkey(action, pressed) if action == "fast_forward_hold" then if pressed then @@ -325,11 +340,7 @@ function Game:touchSkinHotkey(action, pressed) 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() + return self:_cycleSpeed(1) elseif action == "soft_reset" and pressed then Input:reset() TouchControls:reset() @@ -368,6 +379,7 @@ function Game:_step(dt) close() end if TouchControls.pollTouches then TouchControls:pollTouches() end + Game._pollPadPointers(self) self.input:step() -- A+B+SELECT+START held for 16 steps: SoftReset (home/init.asm) stops the -- audio, whites the palettes out and falls through into Init, i.e. the @@ -1411,6 +1423,9 @@ function Game:keypressed(key) local loaded, recovered = SaveData.load() if loaded then self:restoreSave(loaded, recovered) end return + elseif key == "1" then + self:_cycleSpeed(1) + return elseif key == "-" then self:zoomStep(-1) return @@ -1661,12 +1676,14 @@ end function Game:focus(f) Input:reset() TouchControls:reset() + self._padPointerIds = nil self:audioSession(f, "focus") end function Game:visible(v) Input:reset() TouchControls:reset() + self._padPointerIds = nil self:audioSession(v, "visibility") end @@ -1804,13 +1821,35 @@ local function modPointer(self, phase, id, x, y, dx, dy, pressure) return not nativeOwner and p.claimed == true end +-- Options can reset the pad synchronously while a hotbar key is dispatched. +-- Keep gesture ownership separately until release; a changed screen/mod must +-- not inherit the remaining movement/release of that virtual-control press. +function Game:_pollPadPointers() + if not self._padPointerIds or not (love.touch and love.touch.getTouches) then return end + local ok, ids = pcall(love.touch.getTouches) + if not ok or type(ids) ~= 'table' then return end + local live = {} + for _, id in ipairs(ids) do live[id] = true end + for id in pairs(self._padPointerIds) do + if id ~= 'mouse' and not live[id] then self._padPointerIds[id] = nil end + end +end + function Game:touchpressed(id, x, y, dx, dy, pressure) + if not self.secondScreenInjecting and self._padPointerIds then + if self._padPointerIds[id] then TouchControls:touchreleased(id,x,y) end + self._padPointerIds[id] = nil + end -- 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() and TouchControls:hitTest(x,y) then if TouchControls:touchpressed(id,x,y) - or (TouchControls.touches and TouchControls.touches[id]) then return end + or (TouchControls.touches and TouchControls.touches[id]) then + self._padPointerIds = self._padPointerIds or {} + self._padPointerIds[id] = true + 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 @@ -1828,7 +1867,8 @@ function Game:hasPointerScreen() end function Game:touchmoved(id, x, y, dx, dy, pressure) - if not self.secondScreenInjecting and TouchControls.touches and TouchControls.touches[id] then + if not self.secondScreenInjecting and ((self._padPointerIds and self._padPointerIds[id]) + or (TouchControls.touches and TouchControls.touches[id])) then TouchControls:touchmoved(id,x,y) return end @@ -1839,8 +1879,10 @@ function Game:touchmoved(id, x, y, dx, dy, pressure) end function Game:touchreleased(id, x, y, dx, dy, pressure) - if not self.secondScreenInjecting and TouchControls.touches and TouchControls.touches[id] then + if not self.secondScreenInjecting and ((self._padPointerIds and self._padPointerIds[id]) + or (TouchControls.touches and TouchControls.touches[id])) then TouchControls:touchreleased(id,x,y) + if self._padPointerIds then self._padPointerIds[id] = nil end return end if modPointer(self, "released", id, x, y, dx, dy, pressure) then return end diff --git a/src/core/GenOptions.lua b/src/core/GenOptions.lua index 83d96a6d2..ef737cd02 100644 --- a/src/core/GenOptions.lua +++ b/src/core/GenOptions.lua @@ -52,6 +52,7 @@ GenOptions.SHARED_ONLY = { saveSlots = true, touchControls = true, haptics = true, + hotbar = true, tutorials = true, perGeneration = true, dataDir = true, diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index e134442c0..58215e1b7 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -521,6 +521,7 @@ function SaveData.defaultOptions() -- TouchControls.normalizeConfig folds it into both orientations on load. touchControls = { enabled = true }, haptics = "light", + hotbar = true, -- 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 c4c089316..4b7ee70b9 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -63,7 +63,17 @@ local SLOP = { a = 1.3, b = 1.3, x = 1.15, y = 1.15, start = 1.4, select = 1.4, -- and that switches the whole overlay off, so an install whose assets predate -- these two would lose its d-pad. They are skipped instead -- see OPTIONAL. local BUTTONS = { "a", "b", "x", "y", "start", "select", "l", "r" } -local CONTROLS = { "dpad", "a", "b", "x", "y", "start", "select", "l", "r" } +local CONTROLS = { "dpad", "a", "b", "x", "y", "start", "select", "l", "r", "hotbar" } + +local HOTBAR = { + { spec = "key:f1", label = "SAVE" }, + { spec = "key:f2", label = "LOAD" }, + { spec = "key:1", label = "SPEED" }, + { spec = "key:2", label = "COLOR" }, + { spec = "key:3", label = "TILT" }, + { spec = "key:4", label = "ZOOM" }, +} +local HOTBAR_SLOP = 1.4 local function buttonAvailable(self, btn) -- X and Y are Platinum / HGSS buttons in play, but the launcher's layout @@ -234,6 +244,9 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale) b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW }, start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW }, select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW }, + -- Below the native shoulder's hit slop, so neither control steals it. + hotbar = {cx=ox + ww - margin - ssW * 0.70, + cy=oy + margin + lrW * 0.95 + ssW * 0.80, w=ssW}, -- THE SHOULDERS GO AT THE TOP CORNERS, where the hardware puts them and -- where a hand already holding the phone can reach without covering the -- screen. Sized off `dpadW` like everything else here, so the size slider @@ -280,6 +293,7 @@ function TouchControls:init() self.skinId, self.skinError = nil, nil self.hotkeysHeld, self.keysHeld = {}, {} self.haptics = TouchControls.HAPTIC_DEFAULT + self.hotbarEnabled, self.hotbarOpen, self.hotbarControls = true, false, nil 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 @@ -322,6 +336,8 @@ function TouchControls:applyOptions(opts) local cfg = TouchControls.normalizeConfig(opts and opts.touchControls) self.enabled = cfg.enabled self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics) + self.hotbarEnabled = not (opts and opts.hotbar == false) + if not self.hotbarEnabled then self.hotbarOpen = false end TouchSkin.setOverlayLive(self.active) self:selectSkin(cfg.enabled and cfg.skin or nil) self.layouts = cfg.layouts @@ -415,6 +431,19 @@ end local skinHitSet, applySkinSet +function TouchControls:buttons() + local out = {} + for _, name in ipairs(BUTTONS) do if buttonAvailable(self, name) then out[#out+1] = name end end + return out +end + +function TouchControls:controls() + local out = {"dpad"} + for _, name in ipairs(self:buttons()) do out[#out+1] = name end + if self:hotbarShown() then out[#out+1] = "hotbar" end + return out +end + -- 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 @@ -539,6 +568,65 @@ local function inCircle(zone, x, y, slop) return dx * dx + dy * dy <= r * r end +-- Hotbar geometry/drawing adapted from Gen1Recomp535a5ec0. +-- Copyright(C)2026 BOIS CLUB GAMES, LLC; licenses/GEN1RECOMP-TOUCH-SKINS.md. +function TouchControls:hotbarItems() + if self.hotbarControls then return self.hotbarControls end + local out = {} + for i, entry in ipairs(HOTBAR) do + local ctl = TouchSkin.newControl(entry.spec, 0, 0, 0, 0, "rect") + ctl.label = entry.label + out[i] = ctl + end + self.hotbarControls = out + return out +end + +function TouchControls:hotbarShown() + return self.hotbarEnabled ~= false and not TouchSkin.active +end + +function TouchControls:hotbarStrip() + local L = self:layout() + local zone = L and L.hotbar + local items = self:hotbarItems() + if not zone or #items == 0 then return nil end + local ox, oy, sw, sh = (SafeArea.windowRect or SafeArea.rect)() + local h = zone.w * 0.95 + local pad = h * 0.14 + local cw = h * 1.9 + if self.labelFont then + for _, ctl in ipairs(items) do + cw = math.max(cw, self.labelFont:getWidth(ctl.label) + pad * 3) + end + end + local total = math.min(sw - pad * 2, cw * #items) + cw = total / #items + local x0 = math.max(ox + pad, + math.min(zone.cx - total, ox + sw - pad - total)) + local y0 = zone.cy + zone.w * 0.80 + if y0 + h + pad > oy + sh then y0 = zone.cy - zone.w * 0.80 - h end + y0 = math.max(oy + pad, math.min(y0, oy + sh - h - pad)) + local cells = {} + for i, ctl in ipairs(items) do + cells[i] = { ctl = ctl, label = ctl.label, h = h, + x = x0 + (i - 1) * cw, y = y0, w = cw - pad } + end + return cells +end + +function TouchControls:hotbarCellAt(x, y) + if self.preview or not (self.hotbarOpen and self:hotbarShown()) then return nil end + for _, cell in ipairs(self:hotbarStrip() or {}) do + if x >= cell.x and x <= cell.x + cell.w + and y >= cell.y and y <= cell.y + cell.h then + return cell + end + end + return nil +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) @@ -554,6 +642,11 @@ function TouchControls:hitTest(x, y) return nil end local L = self:layout() + if self:hotbarShown() then + local cell = self:hotbarCellAt(x, y) + if cell then return "hotkey", cell.ctl end + if inCircle(L.hotbar, x, y, HOTBAR_SLOP) then return "hotbar" end + end 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 end @@ -712,6 +805,20 @@ function TouchControls:touchpressed(id, x, y) return true end local L = self:layout() + if self:hotbarShown() then + local cell = self:hotbarCellAt(x, y) + if cell then + self.touches[id] = {control="hotkey", ctl=cell.ctl} + enterControl(self, cell.ctl) + return true + end + if inCircle(L.hotbar, x, y, HOTBAR_SLOP) then + self.hotbarOpen = not self.hotbarOpen + self.touches[id] = {control="hotbarToggle"} + TouchControls.buzz(self.haptics) + return true + end + end for _, btn in ipairs(BUTTONS) do if buttonAvailable(self,btn) and L[btn] and inCircle(L[btn], x, y, SLOP[btn]) then self.touches[id] = { control = btn } @@ -783,6 +890,9 @@ function TouchControls:touchreleased(id, x, y) if touch.control == "skin" then applySkinSet(self, touch, nil) + elseif touch.control == "hotkey" then + exitControl(self, touch.ctl) + elseif touch.control == "hotbarToggle" then elseif touch.control == "cameralook" then self.cameraLookTouch = nil self.cameraLookStart = nil @@ -800,7 +910,8 @@ end -- 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 + if touch.control == "skin" then applySkinSet(self, touch, nil) + elseif touch.control == "hotkey" and touch.ctl then exitControl(self, touch.ctl) end end for key in pairs(self.keysHeld or {}) do self.keysHeld[key] = 1 @@ -820,6 +931,7 @@ function TouchControls:reset() self.rightStickTouch = nil self.cameraLookTouch = nil self.cameraLookStart = nil + self.hotbarOpen = false end -- Recover if Android cancels a contact without delivering touchreleased. @@ -963,6 +1075,49 @@ function TouchControls:draw() label("START", L.start) label("SELECT", L.select) + if self:hotbarShown() then + local z = L.hotbar + local r = z.w * 0.5 + love.graphics.setColor(0.15, 0.15, 0.15, 0.55 * alphaMul) + love.graphics.circle("fill", z.cx, z.cy, r * 1.16) + love.graphics.setColor(1, 1, 1, (ALPHA + 0.2) * alphaMul) + local dot = r * 0.16 + for i = -1, 1 do + love.graphics.circle("fill", z.cx + i * r * 0.5, z.cy, dot) + end + if self.hotbarOpen and not self.preview then + local pressed = {} + for _, touch in pairs(self.touches or {}) do + if touch.control == "hotkey" and touch.ctl then pressed[touch.ctl] = true end + end + local cells = self:hotbarStrip() or {} + local first, last = cells[1], cells[#cells] + if first and last then + local pad = first.h * 0.14 + love.graphics.setColor(0.15, 0.15, 0.15, 0.55 * alphaMul) + love.graphics.rectangle("fill", first.x - pad, first.y - pad, + last.x + last.w - first.x + pad * 2, + first.h + pad * 2, first.h * 0.3) + end + for _, cell in ipairs(cells) do + local down = pressed[cell.ctl] == true + love.graphics.setColor(0.3, 0.3, 0.3, + (down and 0.85 or 0.6) * alphaMul) + love.graphics.rectangle("fill", cell.x, cell.y, cell.w, cell.h, + cell.h * 0.25) + local tw = self.labelFont:getWidth(cell.label) + local ty = cell.y + (cell.h - self.labelFont:getHeight()) * 0.5 + love.graphics.setColor(0, 0, 0, 0.6 * alphaMul) + love.graphics.print(cell.label, + cell.x + (cell.w - tw) * 0.5 + 1, ty + 1) + love.graphics.setColor(1, 1, 1, + (down and ALPHA_PRESSED or (ALPHA + 0.2)) * alphaMul) + love.graphics.print(cell.label, cell.x + (cell.w - tw) * 0.5, ty) + end + end + end + + love.graphics.pop() end diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d69ff5714..7a1ec4ae7 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -525,6 +525,13 @@ local function buildRows(game) 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 = 'hotbar', label = Strings('TOUCH HOTBAR'), + value = function(g) return g.save.options.hotbar ~= false and Strings('ON') or Strings('OFF') end, + step = function(g) + g.save.options.hotbar = g.save.options.hotbar == false + require('src.core.TouchControls'):applyOptions(g.save.options) + return true + end } 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) diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua index c69a6956b..98f3f1b69 100644 --- a/src/ui/TouchControlsEditor.lua +++ b/src/ui/TouchControlsEditor.lua @@ -140,7 +140,7 @@ function Editor.draw() -- column between the two shoulders. local Lay = TouchControls:layout() local side = 0 - for _, k in ipairs({ "l", "r" }) do + for _, k in ipairs({ "l", "r", "hotbar" }) do local z = Lay[k] if z then local reach = (k == "l") and (z.cx + z.w * 0.7 - ox) or (ox + ww - (z.cx - z.w * 0.7)) @@ -150,7 +150,7 @@ function Editor.draw() side = math.min(side, ww * 0.3) -- ...and where the shoulders end: the cards below them take the full width local shoulderBottom = oy - for _, k in ipairs({ "l", "r" }) do + for _, k in ipairs({ "l", "r", "hotbar" }) do local z = Lay[k] if z then shoulderBottom = math.max(shoulderBottom, z.cy + z.w * 0.5) end end diff --git a/tests/engine/touch_hotbar_1592.lua b/tests/engine/touch_hotbar_1592.lua new file mode 100644 index 000000000..adff360e9 --- /dev/null +++ b/tests/engine/touch_hotbar_1592.lua @@ -0,0 +1,147 @@ +-- From Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local TC = require("src.core.TouchControls") + +love.window = love.window or {} +local function setRect(w, h) + love.window.getSafeArea = function() return 0, 0, w, h end + love.graphics.getDimensions = function() return w, h end + TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil +end + + +local inList = false +for _, name in ipairs(TC.CONTROLS) do + if name == "hotbar" then inList = true end +end +check(inList, "hotbar is one of the pad's controls, so the editor can drag it") + +for _, r in ipairs({ { 380, 720 }, { 800, 400 } }) do + local L = TC.defaultLayout(r[1], r[2]) + local z = L.hotbar + check(z ~= nil, "defaultLayout places the toggle at " .. r[1] .. "x" .. r[2]) + check(z.cx - z.w / 2 >= 0 and z.cx + z.w / 2 <= r[1], + "the toggle sits inside the rect horizontally") + check(z.cy - z.w / 2 >= 0 and z.cy + z.w / 2 <= r[2], + "and vertically") + check(z.cy < L.dpad.cy, "it is up top, clear of the thumbs") + check(z.cx > L.dpad.cx, "and on the right") +end + + +TC:init() +TC.active = true +TC:ensureImages() +TC.img = TC.img or {} +setRect(380, 720) +TC:applyOptions({ touchControls = { enabled = true } }) +setRect(380, 720) + +local L = TC:layout() +eq(TC:hitTest(L.hotbar.cx, L.hotbar.cy), "hotbar", + "the toggle is what a press at its centre finds") +eq(TC:hitTest(L.a.cx, L.a.cy), "a", "the face buttons are unchanged") + + +check(TC:hotbarStrip() ~= nil, "a strip is laid out") +check(TC:hotbarCellAt(TC:hotbarStrip()[1].x + 1, TC:hotbarStrip()[1].y + 1) == nil, + "but nothing hits while it is folded away") + +check(TC:touchpressed("t1", L.hotbar.cx, L.hotbar.cy), + "the toggle captures its press") +check(TC.hotbarOpen, "and opens the strip") +TC:touchreleased("t1", L.hotbar.cx, L.hotbar.cy) + +local cells = TC:hotbarStrip() +eq(#cells, 6, "six host keys") +local _, _, sw, sh = require("src.core.SafeArea").windowRect() +for _, cell in ipairs(cells) do + check(cell.x >= 0 and cell.x + cell.w <= sw, cell.label .. " fits across") + check(cell.y >= 0 and cell.y + cell.h <= sh, cell.label .. " fits down") +end + + +local keys = {} +local origDown, origUp = love.keypressed, love.keyreleased +love.keypressed = function(key) keys[#keys + 1] = "+" .. key end +love.keyreleased = function(key) keys[#keys + 1] = "-" .. key end + +local save = cells[1] +eq(save.label, "SAVE", "the first cell is quicksave") +local cx, cy = save.x + save.w / 2, save.y + save.h / 2 +local hit = TC:hotbarCellAt(cx, cy) +eq(hit and hit.ctl, save.ctl, "the open strip hit-tests its cells") +check(TC:touchpressed("t2", cx, cy), "a cell captures its press (#807)") +eq(table.concat(keys, " "), "+f1", "the press synthesizes f1 once") +TC:touchreleased("t2", cx, cy) +eq(table.concat(keys, " "), "+f1 -f1", "and the lift releases it once") + +local underA = TC:layout().a +check(TC:hitTest(underA.cx, underA.cy) == "a", "fixture: A is still A") + + +keys = {} +check(TC:touchpressed("t3", cx, cy), "second press lands") +eq(table.concat(keys, " "), "+f1", "held") +TC:reset() +eq(table.concat(keys, " "), "+f1 -f1", "reset releases the synthesized key") +check(not TC.hotbarOpen, "and folds the strip away") + +love.keypressed, love.keyreleased = origDown, origUp + + +TC:applyOptions({ touchControls = { enabled = true }, hotbar = false }) +setRect(380, 720) +L = TC:layout() +check(not TC:hotbarShown(), "options.hotbar = false hides the toggle") +eq(TC:hitTest(L.hotbar.cx, L.hotbar.cy), nil, "nothing to hit there") +check(TC:touchpressed("t4", L.hotbar.cx, L.hotbar.cy) == nil, + "and the press falls through to the mod pointer seam") + +TC:applyOptions({ touchControls = { enabled = true }, hotbar = true }) +setRect(380, 720) +check(TC:hotbarShown(), "and back on again") + + +L = TC:layout() +TC:setControlCenter("hotbar", L.a.cx, L.a.cy) +L = TC:layout() +eq(TC:hitTest(L.a.cx, L.a.cy), "hotbar", + "a toggle dragged onto A is what the editor picks up there") +TC.hotbarOpen = false +check(TC:touchpressed("t5", L.a.cx, L.a.cy), "and the press goes to it too") +check(TC.hotbarOpen, "so hitTest and touchpressed agree on the overlap") +TC:touchreleased("t5", L.a.cx, L.a.cy) +TC.hotbarOpen = false +TC:clearPositions() +setRect(380, 720) + + +TC.img = { + dpad = love.graphics.newImage("assets/touch/dpad.png"), + a = love.graphics.newImage("assets/touch/a.png"), + b = love.graphics.newImage("assets/touch/b.png"), + start = love.graphics.newImage("assets/touch/start.png"), + select = love.graphics.newImage("assets/touch/select.png"), +} +check(TC:visible(), "fixture: the overlay draws at all") +TC.hotbarOpen = true +check(pcall(function() TC:draw() end), "the open strip draws") +TC.hotbarOpen = false +check(pcall(function() TC:draw() end), "and so does the folded toggle") + + +local TouchSkin = require("src.core.TouchSkin") +TouchSkin.setActive(TouchSkin.newSkin("stub")) +check(not TC:hotbarShown(), "a skin suppresses the toggle") +TouchSkin.setActive(nil) + +TC:init() +T.finish("touch_hotbar_1592") From 751f3a6b380fe80500fe6257721f2657e303a3b7 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:10:21 +0100 Subject: [PATCH 50/71] Exercise native hotbar actions and protect persistent-world gestures --- src/core/Game.lua | 2 +- src/core/TouchControls.lua | 5 +- src/ui/TouchControlsEditor.lua | 18 ++- tests/engine/touch_hotbar_1592.lua | 8 ++ tests/engine/touch_hotbar_native.lua | 201 +++++++++++++++++++++++++++ 5 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 tests/engine/touch_hotbar_native.lua diff --git a/src/core/Game.lua b/src/core/Game.lua index 408563182..0b71db29e 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -318,7 +318,7 @@ Game.SKIN_FAST_FORWARD = 4 function Game:_cycleSpeed(dir) if not (self.save and self.save.options) or self:speedLocked() then return false end local ow = self.overworld - local top = self.stack and self.stack:top() + local top = activeTop(self.stack) if ow and (ow.transitioning or (top == ow and ( (ow.runner and ow.runner.isRunning and ow.runner:isRunning()) or (ow.scriptMoves and #ow.scriptMoves > 0) or ow.engaging or ow.emote))) then diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 4b7ee70b9..5686888e2 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -244,9 +244,8 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale) b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW }, start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW }, select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW }, - -- Below the native shoulder's hit slop, so neither control steals it. - hotbar = {cx=ox + ww - margin - ssW * 0.70, - cy=oy + margin + lrW * 0.95 + ssW * 0.80, w=ssW}, + -- Between the native shoulders, above the thumb clusters. + hotbar = {cx=ox + ww * 0.50, cy=oy + margin + ssW * 0.70, w=ssW}, -- THE SHOULDERS GO AT THE TOP CORNERS, where the hardware puts them and -- where a hand already holding the phone can reach without covering the -- screen. Sized off `dpadW` like everything else here, so the size slider diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua index 98f3f1b69..697ed23ca 100644 --- a/src/ui/TouchControlsEditor.lua +++ b/src/ui/TouchControlsEditor.lua @@ -140,7 +140,7 @@ function Editor.draw() -- column between the two shoulders. local Lay = TouchControls:layout() local side = 0 - for _, k in ipairs({ "l", "r", "hotbar" }) do + for _, k in ipairs({ "l", "r" }) do local z = Lay[k] if z then local reach = (k == "l") and (z.cx + z.w * 0.7 - ox) or (ox + ww - (z.cx - z.w * 0.7)) @@ -150,7 +150,7 @@ function Editor.draw() side = math.min(side, ww * 0.3) -- ...and where the shoulders end: the cards below them take the full width local shoulderBottom = oy - for _, k in ipairs({ "l", "r", "hotbar" }) do + for _, k in ipairs({ "l", "r" }) do local z = Lay[k] if z then shoulderBottom = math.max(shoulderBottom, z.cy + z.w * 0.5) end end @@ -164,7 +164,17 @@ function Editor.draw() local titleW = Editor.fonts.title:getWidth("Touch Controls") local narrow = colW < titleW + 2 * btnW + 4 * pad if narrow then btnW = math.min(btnW, (colW - 2 * pad - 10 * s) / 2) end - local rowY = narrow and (oy + pad + barH) or (oy + pad + (barH - btnH) / 2) + local inset = 0 + local toggle = Lay.hotbar + if toggle and TouchControls:hotbarShown() + and toggle.cx + toggle.w * 0.7 > colX + and toggle.cx - toggle.w * 0.7 < colX + colW + and toggle.cy - toggle.w * 0.7 < oy + 260 * s then + inset = math.max(0, toggle.cy + toggle.w * 0.7 - oy + pad) + end + local rowY = narrow and (oy + inset + pad + barH) + or (oy + inset + pad + (barH - btnH) / 2) + barH = barH + inset local done = { x = colX + colW - pad - btnW, y = rowY, w = btnW, h = btnH } local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH } if narrow then barH = barH + btnH + 8 * s end @@ -180,7 +190,7 @@ function Editor.draw() col(PAL.white) -- shrunk to the column when it is narrower than the heading (portrait) local tScale = math.min(1, (colW - 2 * pad) / math.max(1, titleW)) - love.graphics.print("Touch Controls", colX + pad, oy + pad + 4 * s, 0, tScale, tScale) + love.graphics.print("Touch Controls", colX + pad, oy + inset + pad + 4 * s, 0, tScale, tScale) Editor.rects.done, Editor.rects.reset = done, reset diff --git a/tests/engine/touch_hotbar_1592.lua b/tests/engine/touch_hotbar_1592.lua index adff360e9..681cb7c96 100644 --- a/tests/engine/touch_hotbar_1592.lua +++ b/tests/engine/touch_hotbar_1592.lua @@ -6,6 +6,14 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.harness") local check, eq = T.check, T.eq love = love or require("tests.love_stub") +-- Native headless Image fixtures expose dimensions; the real runtime also +-- supports filters. Record that call locally without changing other suites. +local newImage = love.graphics.newImage +love.graphics.newImage = function(...) + local image = newImage(...) + function image:setFilter(min, mag) self.filter = {min, mag} end + return image +end local TC = require("src.core.TouchControls") diff --git a/tests/engine/touch_hotbar_native.lua b/tests/engine/touch_hotbar_native.lua new file mode 100644 index 000000000..933f45c22 --- /dev/null +++ b/tests/engine/touch_hotbar_native.lua @@ -0,0 +1,201 @@ +-- Native hotbar actions, persistent world, pointer ownership and editor. +-- Memory-only persistence; no ROM, player profile, OS touch or GPU claim. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local files={} +local fs=T.sdk.memfs(files) +fs.remove=function(path) files[path]=nil;return true end +fs.createDirectory=function()return true end +fs.getSaveDirectory=function()return '/memory-only-hotbar' end +love.filesystem=fs +love.system=love.system or {} +love.system.getOS=function()return 'Linux'end +local image=love.graphics.newImage +love.graphics.newImage=function(...) + local result=image(...) + function result:setFilter(min,mag) self.filter={min,mag} end + return result +end +local W,H,OX,OY,SW,SH=640,480,0,0,640,480 +love.graphics.getDimensions=function()return W,H end +love.graphics.getPixelDimensions=function()return W*2,H*2 end +love.window=love.window or {} +love.window.getSafeArea=function()return OX,OY,SW,SH end +local Version=require('src.core.GameVersion') +local Save=require('src.core.SaveData') +Save.persistenceFs=function()return fs end +Save.resetSlotState() +Version.set('crystal') +local Touch=require('src.core.TouchControls') +local Input=require('src.core.Input') +local Game=require('src.core.Game') +local Stack=require('src.core.StateStack') +local Runtime=require('src.mods.Runtime') +local Hooks=require('src.mods.Hooks') +local hooks=Hooks.new() +Runtime.install(require('src.mods.Events').new(),hooks,{}) +local stack=setmetatable({},{__index=Stack});stack:init() +local g=setmetatable({save=Save.newGame(),data=T.fixtures.fresh(),stack=stack}, {__index=Game}) +local captures=0 +local world={isOverworld=true,captureSave=function(_,save) + captures=captures+1;save.money=4321 +end} +g.overworld=world;stack.worldState=world +Input:init();Touch:init();Touch.active=true;Touch:ensureImages() +Touch:applyOptions({touchControls={enabled=true}}) +love.keypressed=function(key)g:keypressed(key)end +love.keyreleased=function(key)Input:keyreleased(key)end +local pointers=0 +local unhook=hooks:wrap('input.pointer',function() + pointers=pointers+1;return true +end,0,'hotbar-owner-fixture') +local function tap(label,id) + Touch.hotbarOpen=true + for _,cell in ipairs(Touch:hotbarStrip())do + if cell.label==label then + local x,y=cell.x+cell.w/2,cell.y+cell.h/2 + g:touchpressed(id or label,x,y) + g:touchreleased(id or label,x,y) + return + end + end + error('missing native cell '..label) +end +T.eq(stack:top(),nil,'native Gen2 world is outside overlay stack') +T.eq(stack:active(),world,'actual native stack still owns world') +tap('SAVE') +T.eq(captures,1,'hotbar F1 reaches actual Game.writeSave and live capture') +local saved=Save.load('crystal') +T.check(saved~=nil,'hotbar writes a decodable native save through memory filesystem') +T.eq(saved and saved.money,4321,'actual saved snapshot contains captured world state') +local loaded +-- World admission is separate from IO; verify actual F2/SaveData.load here. +g.continueGame=function(_,snapshot)loaded=snapshot end +tap('LOAD') +T.eq(loaded and loaded.money,4321,'hotbar F2 reads actual persisted snapshot') +T.eq(pointers,0,'visible native cells have priority over claiming mod pointer hook') + +local speed=g.save.options.speed +tap('SPEED') +T.neq(g.save.options.speed,speed,'hotbar SPEED reaches the native speed ladder') +T.eq(Save.optionsFor('crystal').speed,g.save.options.speed,'speed persists through native options IO') +for _,block in ipairs{'transitioning','engaging','emote','scriptMoves','runner'}do + speed=g.save.options.speed + world[block]=block=='scriptMoves' and {'move'} or block=='runner' and {isRunning=function()return true end} or true + tap('SPEED') + T.eq(g.save.options.speed,speed,'persistent Gen2 world blocks speed while '..block) + world[block]=nil +end +stack:push({isFixedSpeed=true}) +speed=g.save.options.speed;tap('SPEED') +T.eq(g.save.options.speed,speed,'native fixed-speed overlay blocks hotbar speed') +stack:pop();g.linkSession={} +tap('SPEED');T.eq(g.save.options.speed,speed,'native link session blocks speed') +g.linkSession=nil +local Palette=require('src.render.PaletteFX') +local Tilt=require('src.render.Tilt') +local Zoom=require('src.render.Zoom') +Tilt.reset();Zoom.reset() +tap('COLOR') +T.eq(g.save.options.colors,Palette.mode,'COLOR writes actual native palette mode') +T.eq(g.save.options.color,Palette.mode=='og' and 'dmg' or Palette.mode, + 'COLOR keeps native Gen2 display preference coherent') +tap('TILT');T.eq(Tilt.level,1,'TILT changes actual native renderer state') +tap('ZOOM');T.eq(Zoom.offset,1,'ZOOM changes actual native zoom state') +local round=Save.optionsFor('crystal') +T.eq(round.tilt,1,'tilt persists through actual options IO') +T.eq(round.zoom,1,'zoom persists through actual options IO') +T.eq(pointers,0,'all six actions retain first refusal over mod pointer consumer') + +-- A synchronous option reapply clears Touch.touches during key dispatch. +-- The host must retain the complete gesture through a newly opened screen. +local moved,released=0,0 +local screen={touchmoved=function()moved=moved+1;return true end, + touchreleased=function()released=released+1;return true end} +g.writeOptions=function(self) + Game.writeOptions(self) + Touch:applyOptions(self.save.options) + self.stack:push(screen) +end +Touch.hotbarOpen=true +local cell=Touch:hotbarStrip()[3] +local x,y=cell.x+cell.w/2,cell.y+cell.h/2 +g:touchpressed('reset-during-press',x,y) +T.eq(Touch.touches['reset-during-press'],nil,'fixture: real option apply resets live pad contacts') +g:touchmoved('reset-during-press',320,240) +g:touchreleased('reset-during-press',320,240) +T.eq(moved,0,'new screen cannot inherit movement from hotbar-owned gesture') +T.eq(released,0,'new screen cannot inherit release from hotbar-owned gesture') +T.eq(pointers,0,'mod cannot inherit remainder of reset pad gesture') +T.eq(g._padPointerIds['reset-during-press'],nil,'release retires host gesture owner') +g.writeOptions=nil;stack:pop() +g._padPointerIds={gone=true,kept=true,mouse=true} +love.touch={getTouches=function()return {'kept'}end} +g:_pollPadPointers() +T.eq(g._padPointerIds.gone,nil,'cancelled native contacts do not leak host owners') +T.check(g._padPointerIds.kept,'poll retains an actual held finger') +T.check(g._padPointerIds.mouse,'OS touch list cannot cancel the mouse owner') +g:touchpressed('gone',320,240) +T.eq(pointers,1,'cancelled id can be used by a new ordinary mod gesture') +g._padPointerIds.reused=true +g:touchpressed('reused',320,240) +T.eq(g._padPointerIds.reused,nil,'new press clears an obsolete id owner') +T.eq(pointers,2,'reused id outside pad reaches actual mod hook') +unhook();Runtime.install(nil,nil,nil) + +-- Safe-area geometry, rotations and edited bottom toggle at native scale limits. +for _,size in ipairs{{380,720},{800,400}}do + W,H=size[1],size[2];OX,OY,SW,SH=12,20,W-24,H-32 + for _,scale in ipairs{.6,1,1.6}do + Touch:init();Touch.active=true;Touch:ensureImages() + Touch:applyOptions({touchControls={enabled=true}});Touch:setScale(scale) + local L=Touch:layout() + for _,name in ipairs{'l','r'}do + T.eq(Touch:hitTest(L[name].cx,L[name].cy),name,'native shoulder remains reachable '..name) + end + for _,bottom in ipairs{false,true}do + if bottom then Touch:setControlCenter('hotbar',OX+SW/2,OY+SH-2) end + L=Touch:layout();Touch.hotbarOpen=true + for _,entry in ipairs(Touch:hotbarStrip())do + T.check(entry.x>=OX and entry.x+entry.w<=OX+SW,'cell stays in safe horizontal area') + T.check(entry.y>=OY and entry.y+entry.h<=OY+SH,'cell stays in safe vertical area') + T.check(not (L.hotbar.cx>=entry.x and L.hotbar.cx<=entry.x+entry.w + and L.hotbar.cy>=entry.y and L.hotbar.cy<=entry.y+entry.h), + 'strip cannot cover its own closing toggle') + end + end + end +end + +-- Normal Options input persists OFF/ON, preserving orientation preferences. +W,H,OX,OY,SW,SH=640,480,0,0,640,480 +love.system.getOS=function()return 'Android'end +g.input={pressed={}} +g.input.wasPressed=function(self,key)return self.pressed[key]end +local menu=require('src.ui.OptionsMenu').new(g);stack:push(menu) +local page=menu:focusRow('hotbar') +T.check(page~=nil,'mobile Options exposes real hotbar row') +if page then + g.input.pressed={right=true};page:update(1/60);g.input.pressed={} + T.eq(g.save.options.hotbar,false,'normal Options input turns hotbar off') + T.eq(Save.optionsFor('crystal').hotbar,false,'hotbar OFF is persisted') + T.eq(Touch:hotbarShown(),false,'OFF reaches real pad') + g.input.pressed={right=true};page:update(1/60);g.input.pressed={} + T.eq(Touch:hotbarShown(),true,'normal Options input restores hotbar') +end +stack:pop() +local Editor=require('src.ui.TouchControlsEditor') +for _,size in ipairs{{380,720},{800,400}}do + W,H=size[1],size[2];SW,SH=W,H + Editor.load();Editor.draw() + local z=Touch:layout().hotbar + T.eq(Touch:hotbarCellAt(z.cx,z.cy),nil,'layout preview has no invisible live cells') + Editor.touchpressed('edit',z.cx,z.cy) + T.eq(Editor.drag and Editor.drag.name,'hotbar','default toggle stays draggable past editor chrome') + Editor.touchmoved('edit',W*.4,H*.5);Editor.touchreleased('edit') + local movedToggle=Touch:layout().hotbar + T.check(math.abs(movedToggle.cx-W*.4)<1,'actual editor drag moves hotbar center') + Editor.unload() +end +Touch:init();Tilt.reset();Zoom.reset() +T.finish('native hotbar actions, ownership, safe area and editor') From 657de03718cb6c26d77c5ac77543d2acf6fcc455 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:12:13 +0100 Subject: [PATCH 51/71] Use explicit Crystal save identity in native hotbar fixture --- tests/engine/touch_hotbar_native.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/engine/touch_hotbar_native.lua b/tests/engine/touch_hotbar_native.lua index 933f45c22..72d6918be 100644 --- a/tests/engine/touch_hotbar_native.lua +++ b/tests/engine/touch_hotbar_native.lua @@ -35,7 +35,7 @@ local Hooks=require('src.mods.Hooks') local hooks=Hooks.new() Runtime.install(require('src.mods.Events').new(),hooks,{}) local stack=setmetatable({},{__index=Stack});stack:init() -local g=setmetatable({save=Save.newGame(),data=T.fixtures.fresh(),stack=stack}, {__index=Game}) +local g=setmetatable({save=Save.newGame({version='crystal'}),data=T.fixtures.fresh(),stack=stack}, {__index=Game}) local captures=0 local world={isOverworld=true,captureSave=function(_,save) captures=captures+1;save.money=4321 @@ -141,7 +141,7 @@ g._padPointerIds.reused=true g:touchpressed('reused',320,240) T.eq(g._padPointerIds.reused,nil,'new press clears an obsolete id owner') T.eq(pointers,2,'reused id outside pad reaches actual mod hook') -unhook();Runtime.install(nil,nil,nil) +unhook() -- Safe-area geometry, rotations and edited bottom toggle at native scale limits. for _,size in ipairs{{380,720},{800,400}}do @@ -198,4 +198,5 @@ for _,size in ipairs{{380,720},{800,400}}do Editor.unload() end Touch:init();Tilt.reset();Zoom.reset() +Runtime.install(nil,nil,nil) T.finish('native hotbar actions, ownership, safe area and editor') From ce06d66a3b477726279fc6eb9fce56078993ae64 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:15:25 +0100 Subject: [PATCH 52/71] Exercise actual offered upstream stack and flat options owner --- src/core/Game.lua | 2 +- tests/engine/touch_hotbar_native.lua | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/core/Game.lua b/src/core/Game.lua index 0b71db29e..b367b005f 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -318,7 +318,7 @@ Game.SKIN_FAST_FORWARD = 4 function Game:_cycleSpeed(dir) if not (self.save and self.save.options) or self:speedLocked() then return false end local ow = self.overworld - local top = activeTop(self.stack) + local top = self.stack and (self.stack.active and self.stack:active() or self.stack:top()) if ow and (ow.transitioning or (top == ow and ( (ow.runner and ow.runner.isRunning and ow.runner:isRunning()) or (ow.scriptMoves and #ow.scriptMoves > 0) or ow.engaging or ow.emote))) then diff --git a/tests/engine/touch_hotbar_native.lua b/tests/engine/touch_hotbar_native.lua index 72d6918be..79ce48612 100644 --- a/tests/engine/touch_hotbar_native.lua +++ b/tests/engine/touch_hotbar_native.lua @@ -40,7 +40,8 @@ local captures=0 local world={isOverworld=true,captureSave=function(_,save) captures=captures+1;save.money=4321 end} -g.overworld=world;stack.worldState=world +-- This offered viewport base retains its real world-on-stack owner. +g.overworld=world;stack:push(world) Input:init();Touch:init();Touch.active=true;Touch:ensureImages() Touch:applyOptions({touchControls={enabled=true}}) love.keypressed=function(key)g:keypressed(key)end @@ -61,8 +62,7 @@ local function tap(label,id) end error('missing native cell '..label) end -T.eq(stack:top(),nil,'native Gen2 world is outside overlay stack') -T.eq(stack:active(),world,'actual native stack still owns world') +T.eq(stack:top(),world,'offered native stack actually owns live world') tap('SAVE') T.eq(captures,1,'hotbar F1 reaches actual Game.writeSave and live capture') local saved=Save.load('crystal') @@ -98,8 +98,6 @@ local Zoom=require('src.render.Zoom') Tilt.reset();Zoom.reset() tap('COLOR') T.eq(g.save.options.colors,Palette.mode,'COLOR writes actual native palette mode') -T.eq(g.save.options.color,Palette.mode=='og' and 'dmg' or Palette.mode, - 'COLOR keeps native Gen2 display preference coherent') tap('TILT');T.eq(Tilt.level,1,'TILT changes actual native renderer state') tap('ZOOM');T.eq(Zoom.offset,1,'ZOOM changes actual native zoom state') local round=Save.optionsFor('crystal') @@ -173,7 +171,10 @@ love.system.getOS=function()return 'Android'end g.input={pressed={}} g.input.wasPressed=function(self,key)return self.pressed[key]end local menu=require('src.ui.OptionsMenu').new(g);stack:push(menu) -local page=menu:focusRow('hotbar') +local page +for index,row in ipairs(menu.rows)do + if row.id=='hotbar' then menu.index=index;page=menu;break end +end T.check(page~=nil,'mobile Options exposes real hotbar row') if page then g.input.pressed={right=true};page:update(1/60);g.input.pressed={} From abd4c8ed18a88652d7427aab775cfc089b0f966e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:17:00 +0100 Subject: [PATCH 53/71] Verify actual upstream quickload restore admission and preserve provenance --- NOTICE.md | 3 +++ tests/engine/touch_hotbar_native.lua | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/NOTICE.md b/NOTICE.md index 476e8a9fa..250d30e81 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -19,3 +19,6 @@ 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. + +The identified hotbar geometry/drawing and adapted hotbar reference test also +originate in that source, under the same preserved GPL/Section7 terms. diff --git a/tests/engine/touch_hotbar_native.lua b/tests/engine/touch_hotbar_native.lua index 79ce48612..51134bc36 100644 --- a/tests/engine/touch_hotbar_native.lua +++ b/tests/engine/touch_hotbar_native.lua @@ -70,7 +70,7 @@ T.check(saved~=nil,'hotbar writes a decodable native save through memory filesys T.eq(saved and saved.money,4321,'actual saved snapshot contains captured world state') local loaded -- World admission is separate from IO; verify actual F2/SaveData.load here. -g.continueGame=function(_,snapshot)loaded=snapshot end +g.restoreSave=function(_,snapshot)loaded=snapshot end tap('LOAD') T.eq(loaded and loaded.money,4321,'hotbar F2 reads actual persisted snapshot') T.eq(pointers,0,'visible native cells have priority over claiming mod pointer hook') From 04b212c9d2674caaeb37921f2a131217f2c75079 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 54/71] 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 b367b005f..3cc4ed74e 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -428,6 +428,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 @@ -436,7 +450,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 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 55/71] 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 38d8d29b81ce4275662c308b550251b7c80f7f29 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 56/71] 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 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 57/71] 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 9a65d7bf868f1d1f70cfe731f640a47933de0134 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:55:33 +0100 Subject: [PATCH 58/71] Offer full skin studio and real native editor and picker ownership --- NOTICE.md | 7 + main.lua | 67 +- src/core/FilePicker.lua | 84 + src/core/HostShell.lua | 13 +- src/core/TouchSkin.lua | 2 +- src/import/RomImporter.lua | 100 +- src/ui/PadCursor.lua | 283 ++ src/ui/SkinStudio.lua | 3070 +++++++++++++++++ src/ui/TouchControlsEditor.lua | 77 +- src/ui/kit/Button.lua | 350 ++ src/ui/kit/FileBrowser.lua | 391 +++ src/ui/kit/Icons.lua | 80 + src/ui/kit/Kit.lua | 1282 +++++++ src/ui/kit/Layout.lua | 132 + src/ui/kit/Loader.lua | 135 + src/ui/kit/Theme.lua | 526 +++ src/ui/kit/Toast.lua | 153 + src/ui/kit/Transition.lua | 134 + src/ui/kit/VirtualKeyboard.lua | 310 ++ tests/engine/kit_spinner_trail_bug2048.lua | 50 + tests/engine/kit_transition.lua | 114 + tests/engine/native_file_picker.lua | 68 + tests/engine/skin_studio_image_import.lua | 122 + .../skin_studio_library_reach_bug1912.lua | 176 + tests/engine/skin_studio_native_host.lua | 138 + tests/engine/skin_studio_test.lua | 388 +++ tests/engine/skin_studio_ux.lua | 387 +++ .../engine/touch_controls_pad_cursor_test.lua | 71 + tests/engine/ui_kit_pagination.lua | 129 + 29 files changed, 8751 insertions(+), 88 deletions(-) create mode 100644 src/core/FilePicker.lua create mode 100644 src/ui/PadCursor.lua create mode 100644 src/ui/SkinStudio.lua create mode 100644 src/ui/kit/Button.lua create mode 100644 src/ui/kit/FileBrowser.lua create mode 100644 src/ui/kit/Icons.lua create mode 100644 src/ui/kit/Kit.lua create mode 100644 src/ui/kit/Layout.lua create mode 100644 src/ui/kit/Loader.lua create mode 100644 src/ui/kit/Theme.lua create mode 100644 src/ui/kit/Toast.lua create mode 100644 src/ui/kit/Transition.lua create mode 100644 src/ui/kit/VirtualKeyboard.lua create mode 100644 tests/engine/kit_spinner_trail_bug2048.lua create mode 100644 tests/engine/kit_transition.lua create mode 100644 tests/engine/native_file_picker.lua create mode 100644 tests/engine/skin_studio_image_import.lua create mode 100644 tests/engine/skin_studio_library_reach_bug1912.lua create mode 100644 tests/engine/skin_studio_native_host.lua create mode 100644 tests/engine/skin_studio_test.lua create mode 100644 tests/engine/skin_studio_ux.lua create mode 100644 tests/engine/touch_controls_pad_cursor_test.lua create mode 100644 tests/engine/ui_kit_pagination.lua diff --git a/NOTICE.md b/NOTICE.md index 250d30e81..7277db373 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -22,3 +22,10 @@ same Gen1Recomp source under the same preserved GPL/Section7 grant. The identified hotbar geometry/drawing and adapted hotbar reference test also originate in that source, under the same preserved GPL/Section7 terms. + +SkinStudio, PadCursor, identified cursor sections in TouchControlsEditor, +widget toolkit (except native vector Icons) and adapted studio tests originate +in the same Gen1Recomp revision under preserved GPLv3/Section7 terms. Native +host integration, FilePicker/command provider and all62 vector icons are +contributions by Roxas2712, subject to LICENSE-ROXAS2712.md and prior grants. +Excluded Gen1 launcher files and artwork are not copied. diff --git a/main.lua b/main.lua index 15d0c1b6e..947c1a23e 100644 --- a/main.lua +++ b/main.lua @@ -209,15 +209,40 @@ local closeTouchControlsEditor -- forward declaration local function openTouchControlsEditor() touchEditorHost = Importer + if touchEditorHost then touchEditorHost:prepareOverlayHandoff() end Importer = nil TouchEditor = require("src.ui.TouchControlsEditor") TouchEditor.load({ onClose = function() closeTouchControlsEditor() end }) end +local function openTouchSkinStudio() + touchEditorHost = Importer + if touchEditorHost then touchEditorHost:prepareOverlayHandoff() end + Importer = nil + TouchEditor = require('src.ui.SkinStudio') + local version = touchEditorHost and touchEditorHost.tab + local Version = require('src.core.GameVersion') + if not Version.VERSIONS[version] then version = Version.get() end + TouchEditor.load({version=version, + onClose=function() closeTouchControlsEditor() end, + onPlay=function(chosen) + closeTouchControlsEditor() + if Importer then Importer:play(chosen) end + end}) +end + +-- The default position editor and full skin studio share one suspended host. +-- Optional events stay owned by the active editor even when it has no handler. +local function touchEditorEvent(name, ...) + local fn = TouchEditor and TouchEditor[name] + if fn then return fn(...) end +end + function closeTouchControlsEditor() if TouchEditor and TouchEditor.unload then TouchEditor.unload() end TouchEditor = nil Importer = touchEditorHost + if Importer then Importer:resumeAfterOverlay() end touchEditorHost = nil end @@ -395,6 +420,7 @@ local function openLauncher(forceImport) onEditSave = openEditor, onEditMaps = openMapEditor, onEditTouchControls = openTouchControlsEditor, + onEditTouchSkins = openTouchSkinStudio, }) end @@ -764,7 +790,7 @@ end function love.gamepadpressed(joystick, button) -- and the dismissal that matters on the Switch. if bootReport then return dismissBootReport() end - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('gamepadpressed', joystick, button) end if editorMode then if EditorApp.gamepadpressed then return EditorApp.gamepadpressed(button) end return @@ -775,7 +801,7 @@ function love.gamepadpressed(joystick, button) end function love.gamepadreleased(joystick, button) - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('gamepadreleased', joystick, button) end if editorMode then if EditorApp.gamepadreleased then return EditorApp.gamepadreleased(button) end return @@ -786,7 +812,7 @@ function love.gamepadreleased(joystick, button) end function love.gamepadaxis(joystick, axis, value) - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('gamepadaxis', joystick, axis, value) end if editorMode then if EditorApp.gamepadaxis then return EditorApp.gamepadaxis(axis, value) end return @@ -798,7 +824,7 @@ end function love.joystickpressed(joystick, button) if bootReport then return dismissBootReport() end - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('joystickpressed', joystick, button) end if editorMode then if EditorApp.joystickpressed then return EditorApp.joystickpressed(joystick, button) @@ -811,14 +837,15 @@ function love.joystickpressed(joystick, button) end function love.joystickreleased(joystick, button) - if editorMode or TouchEditor then return end + if TouchEditor then return touchEditorEvent('joystickreleased', joystick, button) end + if editorMode then return end if Importer then return Importer:joystickreleased(joystick, button) end if not Game then return end Game:joystickreleased(joystick, button) end function love.joystickaxis(joystick, axis, value) - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('joystickaxis', joystick, axis, value) end if editorMode then if EditorApp.joystickaxis then return EditorApp.joystickaxis(joystick, axis, value) @@ -831,7 +858,7 @@ function love.joystickaxis(joystick, axis, value) end function love.joystickhat(joystick, hat, direction) - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('joystickhat', joystick, hat, direction) end if editorMode then if EditorApp.joystickhat then return EditorApp.joystickhat(joystick, hat, direction) @@ -844,7 +871,8 @@ function love.joystickhat(joystick, hat, direction) end function love.joystickremoved(joystick) - if editorMode or TouchEditor then return end + if TouchEditor then return touchEditorEvent('joystickremoved', joystick) end + if editorMode then return end if Importer then return end if not Game then return end Game:joystickremoved(joystick) @@ -857,7 +885,8 @@ function love.focus(f) if bootReport then return end -- a phone stops the frames in the background: the walker credits the gap walkerTick("focus", f) - if editorMode or TouchEditor then return end + if TouchEditor then return touchEditorEvent('focus', f) end + if editorMode then return end if Importer then if Importer.focus then Importer:focus(f) end return @@ -869,7 +898,8 @@ end -- v is true when the window becomes visible again, false on minimize. function love.visible(v) if bootReport then return end - if editorMode or TouchEditor then return end + if TouchEditor then return touchEditorEvent('visible', v) end + if editorMode then return end if Importer then return end if not Game then return end Game:visible(v) @@ -887,7 +917,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure) if TouchEditor then -- iOS synthesizes mousepressed for the primary touch (same as the -- launcher); Android drives the editor through love.touch directly. - if love.system.getOS() == "iOS" then return end + if love.system.getOS() == "iOS" and not TouchEditor.touchFirst then return end return TouchEditor.touchpressed(id, x, y) end if Importer then return Importer:touchpressed(id,x,y,dx,dy,pressure) end @@ -901,7 +931,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure) return end if TouchEditor then - if love.system.getOS() == "iOS" then return end + if love.system.getOS() == "iOS" and not TouchEditor.touchFirst then return end return TouchEditor.touchmoved(id, x, y) end if Importer then return Importer:touchmoved(id,x,y,dx,dy,pressure) end @@ -915,7 +945,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure) return end if TouchEditor then - if love.system.getOS() == "iOS" then return end + if love.system.getOS() == "iOS" and not TouchEditor.touchFirst then return end return TouchEditor.touchreleased(id, x, y) end if Importer then return Importer:touchreleased(id,x,y,dx,dy,pressure) end @@ -928,7 +958,7 @@ function love.wheelmoved(x, y) if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end return end - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('wheelmoved', x, y) end if Importer then return end if not Game then return end Game:wheelmoved(x, y) @@ -939,7 +969,7 @@ function love.mousepressed(x, y, button, istouch) if TouchEditor then -- Android primary touch already arrived via love.touchpressed; a second -- mouse path would double-fire Done / begin a second drag. - if love.system.getOS() == "Android" then return end + if istouch and (TouchEditor.touchFirst or love.system.getOS() == "Android") then return end return TouchEditor.mousepressed(x, y, button) end if Importer then @@ -960,7 +990,7 @@ end function love.mousereleased(x, y, button, istouch) if TouchEditor then - if love.system.getOS() == "Android" then return end + if istouch and (TouchEditor.touchFirst or love.system.getOS() == "Android") then return end return TouchEditor.mousereleased(x, y, button) end if Importer then return end @@ -1003,7 +1033,7 @@ function love.mousemoved(x, y, dx, dy, istouch) return end if TouchEditor then - if love.system.getOS() == "Android" then return end + if istouch and (TouchEditor.touchFirst or love.system.getOS() == "Android") then return end return TouchEditor.mousemoved(x, y) end if editorMode then @@ -1017,7 +1047,7 @@ function love.mousemoved(x, y, dx, dy, istouch) end function love.textinput(text) - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('textinput', text) end if Importer then return Importer:textinput(text) end if editorMode and EditorApp.textinput then return EditorApp.textinput(text) @@ -1193,6 +1223,7 @@ function love.quit() end function love.filedropped(file) + if TouchEditor then return touchEditorEvent('filedropped', file) end if editorMode and EditorApp and EditorApp.filedropped then return EditorApp.filedropped(file) end diff --git a/src/core/FilePicker.lua b/src/core/FilePicker.lua new file mode 100644 index 000000000..cca832874 --- /dev/null +++ b/src/core/FilePicker.lua @@ -0,0 +1,84 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared desktop picker contract. RomImporter retains the one dialog launch +-- path, including pointer-grab release; importing this module starts nothing. +local Host=require('src.core.HostShell') +local M={IMAGE={label='Image',exts={'png','jpg','jpeg'}}} +local function promptOk(value)return type(value)=='string'and #value<=512 and not value:find('%z')end +local function kindOf(kind) + kind=kind or M.IMAGE + if type(kind)~='table'or type(kind.exts)~='table'or #kind.exts<1 or #kind.exts>16 then return end + local label=kind.label or 'File' + if type(label)~='string'or #label>128 or label:find('[%z\r\n|]')then return end + local exts,seen={},{} + for _,ext in ipairs(kind.exts)do + if type(ext)~='string'or #ext>24 or not ext:match('^[%w][%w_.-]*$')then return end + ext=ext:lower();if not seen[ext]then seen[ext]=true;exts[#exts+1]=ext end + end + return{label=label,exts=exts} +end +local function backend() + if not Host.canSpawnProcess()then return end + local os=love.system.getOS() + if os=='OS X'or os=='Windows'then return os end + if os=='Linux'then + if Host.hasCommand('zenity')then return 'zenity'end + if Host.hasCommand('kdialog')then return 'kdialog'end + end +end +function M.available()return backend()~=nil end +function M.matches(name,kind) + local k=kindOf(kind);if not k or type(name)~='string'then return false end + name=name:lower() + for _,ext in ipairs(k.exts)do if name:sub(-#ext-1)=='.'..ext then return true end end + return false +end +local function shell(value)return "'"..value:gsub("'","'\\''").."'"end +local function apple(value)return '"'..value:gsub('\\','\\\\'):gsub('"','\\"'):gsub('\n','\\n'):gsub('\r','\\r')..'"'end +-- Untrusted UTF-8 travels as decimal bytes, never as PowerShell/cmd syntax. +local function windows(value) + local bytes={};for i=1,#value do bytes[i]=tostring(value:byte(i))end + return '([Text.Encoding]::UTF8.GetString([byte[]]@('..table.concat(bytes,',')..')))' +end +function M.commands(prompt,kind) + local k=kindOf(kind) + if not k or not promptOk(prompt)then return end + local os=backend();if not os then return end + local globs,types={},{} + for i,ext in ipairs(k.exts)do globs[i]='*.'..ext;types[i]=apple(ext)end + if os=='OS X'then + local script='POSIX path of (choose file with prompt '..apple(prompt)..' of type {'..table.concat(types,', ')..'})' + return{'osascript -e '..shell(script)..' 2>/dev/null'} + elseif os=='Windows'then + local patterns=table.concat(globs,';') + local filter=k.label..' ('..patterns..')|'..patterns..'|All files (*.*)|*.*' + local script=table.concat({'Add-Type -AssemblyName System.Windows.Forms;', + '$d=New-Object System.Windows.Forms.OpenFileDialog;','$d.Title='..windows(prompt)..';', + '$d.Filter='..windows(filter)..';',"if($d.ShowDialog() -eq 'OK'){$p=$d.FileName;$ascii=$true;", + 'foreach($c in $p.ToCharArray()){if([int]$c -lt 32 -or [int]$c -gt 126){$ascii=$false}};', + '[Console]::OutputEncoding=[Text.Encoding]::UTF8;if($ascii){[Console]::Write($p)}else{', + "$t=Join-Path $env:TEMP ('pokeport_base_'+[Guid]::NewGuid().ToString('N')+'.bin');", + 'try{Copy-Item -LiteralPath $p -Destination $t -ErrorAction Stop;[Console]::Write($t)}catch{}}}'}) + local command='powershell -NoProfile -STA -Command "'..script..'"' + if #command>7000 then return end + return{command} + elseif os=='zenity'then + return{'zenity --file-selection --title='..shell(prompt)..' --file-filter='..shell(k.label..' | '..table.concat(globs,' '))..' 2>/dev/null'} + else + return{'kdialog --getopenfilename "$HOME" '..shell(table.concat(globs,' ')..'|'..k.label)..' --title '..shell(prompt)..' 2>/dev/null'} + end +end +function M.open(prompt,kind) + local k=kindOf(kind);if not k or not promptOk(prompt)then return end + return require('src.import.RomImporter').chooseFileByExt(k.exts,nil,{title=prompt,label=k.label}) +end +function M.basename(path)return tostring(path or''):match('([^/\\]+)$')or tostring(path or'')end +function M.read(path) + if type(path)~='string'or path:find('%z')then return nil,'invalid native file path'end + local opened,file,why=pcall(io.open,path,'rb') + if not opened or not file then return nil,opened and why or file end + local ok,bytes,err=pcall(file.read,file,'*a');pcall(file.close,file) + if not ok then return nil,bytes end + if not bytes or bytes==''then return nil,err or 'empty file'end + return bytes +end +return M diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 72e75e2ba..f13d248b1 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -146,7 +146,18 @@ end function HostShell.toLauncher() if launcherHook then return launcherHook() end - return HostShell.restart() + function HostShell.hasCommand(name) + if not HostShell.canSpawnProcess() or type(name)~='string' or #name>80 + or not name:match('^[%w][%w_.+-]*$') then return false end + local windows=love.system.getOS()=='Windows' + local pipe=HostShell.popen(windows and('where '..name..' 2>NUL')or("command -v '"..name.."' 2>/dev/null")) + if not pipe then return false end + local ok,value=pcall(pipe.read,pipe,'*a') + pcall(pipe.close,pipe) + return ok and type(value)=='string'and value:find('%S')~=nil +end + +return HostShell.restart() end function HostShell.restart() diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index cbaa824d4..d37b9acde 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", start = "start", select = "select", + 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", diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 90da0c712..cb4f61612 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2717,73 +2717,14 @@ end -- The extension list comes from the manifest rather than being fixed, because -- what a mod needs is its own business: STADIUM2_IMPORTER wants a Nintendo 64 -- cartridge in any of its three byte orders (.z64/.n64/.v64). -local function chooseFileByExt(exts, promptName) - if type(exts) ~= "table" or #exts == 0 then return nil end - local prompt = Strings("Choose your %s", promptName or "file") - local platform = love.system.getOS() - local quoted, globs, semis = {}, {}, {} - for index, ext in ipairs(exts) do - quoted[index] = '"' .. ext .. '"' - globs[index] = "*." .. ext - semis[index] = "*." .. ext - end - if platform == "OS X" then - return commandOutput( - ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {%s})' 2>/dev/null]]) - :format(prompt, table.concat(quoted, ", "))) - elseif platform == "Windows" then - local script = table.concat({ - "Add-Type -AssemblyName System.Windows.Forms;", - "$d=New-Object System.Windows.Forms.OpenFileDialog;", - "$d.Title='" .. prompt .. "';", - "$d.Filter='Base file (" .. table.concat(semis, ";") .. ")|" - .. table.concat(semis, ";") .. "|All files (*.*)|*.*';", - -- THE TEMP COPY IS THE FALLBACK NOW, NOT THE DEFAULT. - -- - -- io.open on Windows needs ANSI bytes, so a path with characters outside - -- them cannot be opened at all (#325) and the pick has to be copied to - -- an ASCII temp name first. That was done for EVERY pick, on the - -- reasoning that "a cartridge is tens of megabytes, so this copy is the - -- one slow step" -- which stopped being true the moment a mod could - -- declare a GameCube disc. A 1.4 GB pick was copied into %TEMP% on the - -- system drive before anything looked at it: minutes of apparent freeze, - -- 1.4 GB of C: needed to import a file already sitting on G:, and -- the - -- reported failure -- Copy-Item leaves $ErrorActionPreference at - -- Continue, so a copy that ran out of space still fell through to - -- writing the temp path, and the launcher opened a file that was never - -- created. "that file could not be opened", for a file that was right - -- there. - -- - -- So: an ASCII path is handed back AS IS, which is every ordinary path - -- and costs nothing. Only a path io.open genuinely cannot take is - -- copied, and that copy now has to prove it landed before its - -- destination is reported -- otherwise nothing is written and the - -- caller falls through to the "copy it into imports/" instructions, - -- which at least tell the player what to do next. - "if($d.ShowDialog() -eq 'OK'){", - "$p=$d.FileName;", - "$ascii=$true;", - "foreach($c in $p.ToCharArray()){", - "if([int]$c -lt 32 -or [int]$c -gt 126){$ascii=$false}};", - "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", - "if($ascii){[Console]::Write($p)}else{", - "$t=Join-Path $env:TEMP 'pokeport_base_pick.bin';", - "Copy-Item -LiteralPath $p -Destination $t -Force " - .. "-ErrorAction SilentlyContinue;", - "if(Test-Path -LiteralPath $t){[Console]::Write($t)}}}", - }) - return commandOutput( - 'powershell -NoProfile -STA -Command "' .. script .. '"') - elseif platform == "Linux" then - local path = commandOutput( - ([[zenity --file-selection --title="%s" --file-filter="Base file | %s" 2>/dev/null]]) - :format(prompt, table.concat(globs, " "))) - if path then return path end - return commandOutput( - ([[kdialog --getopenfilename "$HOME" "%s|Base file" 2>/dev/null]]) - :format(table.concat(globs, " "))) +local function chooseFileByExt(exts, promptName, opts) + opts = opts or {} + local prompt = opts.title or Strings("Choose your %s", promptName or "file") + local commands = require('src.core.FilePicker').commands(prompt, {label=opts.label or 'Base file',exts=exts}) + for _,command in ipairs(commands or {}) do + -- One selected backend: cancelling its dialog must not open another one. + return commandOutput(command) end - return nil end -- PUBLISHED, because the map editor needs a picker too. @@ -2879,6 +2820,7 @@ function RomImporter.new(onComplete, opts) forceImport = opts.forceImport or false, onEditSave = opts.onEditSave, onEditTouchControls = opts.onEditTouchControls, + onEditTouchSkins = opts.onEditTouchSkins, onEditMaps = opts.onEditMaps, android = android, -- Read by the three console-inbox branches below. It was never assigned, @@ -5025,6 +4967,27 @@ function RomImporter:_activatePadCursor() self._padCursorActive = true end +-- Native overlay handoff: a release may be consumed by the editor rather +-- than this suspended launcher. Drop gesture/axis ownership at both edges, +-- retaining only cursor position and whether the player was using a pad. +function RomImporter:prepareOverlayHandoff() + self._overlayPadActive = self._padCursorActive == true + self._slotPress, self._modPress, self._slotDrag = nil, nil, nil + self._touchGesture = nil + self._padAxis, self._padDir, self._rawHatDirs = {}, {}, {} + self._padCursorActive = false + self._lastMouseX, self._lastMouseY = nil, nil +end + +function RomImporter:resumeAfterOverlay() + self._slotPress, self._modPress, self._slotDrag = nil, nil, nil + self._touchGesture = nil + self._padAxis, self._padDir, self._rawHatDirs = {}, {}, {} + self._padCursorActive = self._overlayPadActive == true + self._overlayPadActive = nil + self._lastMouseX, self._lastMouseY = nil, nil +end + -- THE ROW AS IT IS ON SCREEN, not a list written when there were three games. -- -- This named red / blue / yellow / mods / find and nothing else, so a gamepad @@ -8693,6 +8656,9 @@ function RomImporter:_settingsRows() add({ kind = "action", label = "EDIT LAYOUT", value = "OPEN", action = "touchLayout" }) end + if self.onEditTouchSkins then + add({kind='action',label='SKIN LIBRARY / EDITOR',value='OPEN',action='touchSkins'}) + end -- MOD OPTIONS, one section per installed mod that declares any. Read through -- LauncherMods, which parses the mod's declared schema FILE rather than @@ -9210,6 +9176,8 @@ function RomImporter:_settingsAction(entry) self._settingsRowCache=nil elseif action == "touchLayout" then if self.onEditTouchControls then self.onEditTouchControls() end + elseif action == 'touchSkins' then + if self.onEditTouchSkins then self.onEditTouchSkins() end elseif action == "launcherPage" then self.settingsPage = "launcher" self.settingsScroll = 0 diff --git a/src/ui/PadCursor.lua b/src/ui/PadCursor.lua new file mode 100644 index 000000000..4348df823 --- /dev/null +++ b/src/ui/PadCursor.lua @@ -0,0 +1,283 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Virtual pointer for overlay hosts (save editor, touch-controls editor) on +-- Switch / handhelds / any gamepad. Mirrors the launcher's RomImporter pad +-- cursor (speeds, deadzone, dual-path raw gate) without sharing that module. +-- +-- Stick / D-pad move; real mouse motion yields so desktop stays normal. +-- Callers map A → click, B → close, shoulders → host-specific actions, right +-- stick → wheel notches (save editor lists). + +local SafeArea = require("src.core.SafeArea") +local GamepadMap = require("src.core.GamepadMap") + +local PAD_DEAD = 0.28 +local PAD_SPEED = 560 +local PAD_DPAD_SPEED = 420 +-- Right stick → Kit wheel notches: ~2 notches/sec at full deflection so lists +-- scroll at a usable pace without flooding one frame. +local PAD_WHEEL_RATE = 2.0 +local TRIGGER_NOTCHES = 3 +local TRIGGER_REPEAT_DELAY = 0.35 +local TRIGGER_REPEAT_EVERY = 0.08 +local WHEEL_CAP = 12 + +local PadCursor = {} + +local cursor = { x = 0, y = 0 } +local active = false +local inited = false +local axis = { leftx = 0, lefty = 0, righty = 0 } +local dir = {} +local rawHatDirs = {} +local lastMouseX, lastMouseY +local wheelAcc = 0 +local triggers = {} + +local function addWheel(n) + wheelAcc = math.max(-WHEEL_CAP, math.min(WHEEL_CAP, wheelAcc + n)) +end + +local function activate() + if active then return end + local ox, oy, w, h = SafeArea.rect() + if not inited then + cursor.x = ox + w * 0.5 + cursor.y = oy + h * 0.45 + inited = true + end + active = true +end + +function PadCursor.reset() + cursor.x, cursor.y = 0, 0 + active = false + inited = false + axis.leftx, axis.lefty, axis.righty = 0, 0, 0 + for k in pairs(dir) do dir[k] = nil end + for k in pairs(rawHatDirs) do rawHatDirs[k] = nil end + lastMouseX, lastMouseY = nil, nil + wheelAcc = 0 + for k in pairs(triggers) do triggers[k] = nil end +end + +local function triggerDown(side, src) + local t = triggers[side] + if t then + t.src[src] = true + return + end + activate() + triggers[side] = { held = 0, rep = 0, src = { [src] = true } } + addWheel(side == "l" and TRIGGER_NOTCHES or -TRIGGER_NOTCHES) +end + +local function triggerUp(side, src) + local t = triggers[side] + if not t then return end + t.src[src] = nil + if next(t.src) == nil then triggers[side] = nil end +end + +local function triggerAxis(side, src, value) + if value > GamepadMap.TRIGGER_ON then + triggerDown(side, src) + elseif value < GamepadMap.TRIGGER_OFF then + triggerUp(side, src) + end +end + +local TRIGGER_SIDE = { + triggerleft = "l", lefttrigger = "l", l2 = "l", + triggerright = "r", righttrigger = "r", r2 = "r", +} + +-- Touch / mouse press: drop the virtual cursor for this interaction so a tap +-- is not swallowed by the Joy-Con pointer sitting elsewhere on screen. +function PadCursor.yieldToPointer() + active = false +end + +-- Returns mx, my, isActive. When inactive the caller should use the system +-- mouse; when active these coords feed hit-tests / draws. +function PadCursor.pointer() + return cursor.x, cursor.y, active +end + +function PadCursor.isActive() + return active +end + +-- Consume accumulated right-stick scroll as integer wheel notches (same +-- units App.wheelmoved feeds Kit). Fractional remainder stays for next frame. +function PadCursor.takeWheel() + local notches = 0 + if wheelAcc >= 1 or wheelAcc <= -1 then + notches = wheelAcc > 0 and math.floor(wheelAcc) or math.ceil(wheelAcc) + wheelAcc = wheelAcc - notches + end + return notches +end + +function PadCursor.update(dt) + if not (love and love.mouse and love.mouse.getPosition) then return end + local mx, my = love.mouse.getPosition() + if lastMouseX and active then + if math.abs(mx - lastMouseX) > 3 or math.abs(my - lastMouseY) > 3 then + active = false + end + end + lastMouseX, lastMouseY = mx, my + + local ax = axis.leftx or 0 + local ay = axis.lefty or 0 + local dx, dy = 0, 0 + if math.abs(ax) > PAD_DEAD then dx = dx + ax end + if math.abs(ay) > PAD_DEAD then dy = dy + ay end + if dir.dpleft then dx = dx - 1 end + if dir.dpright then dx = dx + 1 end + if dir.dpup then dy = dy - 1 end + if dir.dpdown then dy = dy + 1 end + + if dx ~= 0 or dy ~= 0 then + activate() + local mag = math.sqrt(dx * dx + dy * dy) + if mag > 1 then dx, dy = dx / mag, dy / mag end + local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD) + and PAD_SPEED or PAD_DPAD_SPEED + local ox, oy, w, h = SafeArea.rect() + local nx = cursor.x + dx * speed * dt + local ny = cursor.y + dy * speed * dt + cursor.x = math.max(ox, math.min(ox + w, nx)) + cursor.y = math.max(oy, math.min(oy + h, ny)) + end + + local ry = axis.righty or 0 + if math.abs(ry) > PAD_DEAD then + activate() + -- Negative righty (stick up) scrolls lists up = positive wheel notches. + addWheel((-ry) * PAD_WHEEL_RATE * dt) + end + + for side, t in pairs(triggers) do + t.held = t.held + dt + if t.held >= TRIGGER_REPEAT_DELAY then + t.rep = t.rep + dt + while t.rep >= TRIGGER_REPEAT_EVERY do + t.rep = t.rep - TRIGGER_REPEAT_EVERY + addWheel(side == "l" and 1 or -1) + end + end + end +end + +-- Returns a string action the host handles: +-- "a" | "b" | "tab_prev" | "tab_next" | nil +function PadCursor.gamepadpressed(_, button) + activate() + local action = GamepadMap.mapGamepadButton(button) + if action == "a" or action == "b" then + return action + elseif button == "leftshoulder" then + return "tab_prev" + elseif button == "rightshoulder" then + return "tab_next" + elseif TRIGGER_SIDE[button] then + triggerDown(TRIGGER_SIDE[button], "button") + elseif button == "dpup" or button == "dpdown" + or button == "dpleft" or button == "dpright" then + dir[button] = true + end + return nil +end + +function PadCursor.gamepadreleased(_, button) + if TRIGGER_SIDE[button] then + triggerUp(TRIGGER_SIDE[button], "button") + elseif button == "dpup" or button == "dpdown" + or button == "dpleft" or button == "dpright" then + dir[button] = nil + end +end + +function PadCursor.gamepadaxis(_, axisName, value) + if axisName == "leftx" or axisName == "lefty" or axisName == "righty" then + axis[axisName] = value + if math.abs(value) > PAD_DEAD then activate() end + elseif TRIGGER_SIDE[axisName] then + triggerAxis(TRIGGER_SIDE[axisName], "axis", value) + end +end + +function PadCursor.joystickpressed(joystick, button) + if GamepadMap.ignoreRawForJoystick(joystick) then return nil end + if GamepadMap.isAccelerometer(joystick) then return nil end + local padButton = GamepadMap.mapRawToGamepadButton(button) + if padButton then return PadCursor.gamepadpressed(joystick, padButton) end + return nil +end + +function PadCursor.joystickreleased(joystick, button) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end + local padButton = GamepadMap.mapRawToGamepadButton(button) + if padButton then PadCursor.gamepadreleased(joystick, padButton) end +end + +function PadCursor.joystickaxis(joystick, axisIndex, value) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end + if axisIndex == 1 then + PadCursor.gamepadaxis(joystick, "leftx", value) + elseif axisIndex == 2 then + PadCursor.gamepadaxis(joystick, "lefty", value) + elseif axisIndex == 3 or axisIndex == 5 then + triggerAxis("l", "raw", value) + elseif axisIndex == 4 or axisIndex == 6 then + triggerAxis("r", "raw", value) + end +end + +function PadCursor.joystickhat(joystick, hat, direction) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end + for _, d in ipairs(rawHatDirs[hat] or {}) do + dir[d] = nil + end + local dirs = ({ + u = { "dpup" }, d = { "dpdown" }, l = { "dpleft" }, r = { "dpright" }, + lu = { "dpleft", "dpup" }, ru = { "dpright", "dpup" }, + ld = { "dpleft", "dpdown" }, rd = { "dpright", "dpdown" }, + })[direction] or {} + for _, d in ipairs(dirs) do dir[d] = true end + rawHatDirs[hat] = dirs + if #dirs > 0 then activate() end +end + +function PadCursor.draw() + if not active then return end + if not (love and love.graphics) then return end + local x, y = cursor.x, cursor.y + love.graphics.push("all") + if love.graphics.origin then love.graphics.origin() end + if love.graphics.setLineWidth then love.graphics.setLineWidth(1) end + love.graphics.setColor(0, 0, 0, 0.45) + if love.graphics.polygon then + love.graphics.polygon("fill", + x + 2, y + 2, x + 2, y + 22, x + 8, y + 16, x + 14, y + 26, + x + 18, y + 24, x + 11, y + 14, x + 20, y + 14) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.polygon("fill", + x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, + x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) + love.graphics.setColor(0.05, 0.07, 0.12, 1) + love.graphics.polygon("line", + x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, + x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) + else + love.graphics.rectangle("fill", x, y, 12, 18) + end + love.graphics.pop() +end + +return PadCursor diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua new file mode 100644 index 000000000..7b614f874 --- /dev/null +++ b/src/ui/SkinStudio.lua @@ -0,0 +1,3070 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL +local TouchSkin = require("src.core.TouchSkin") +local TouchControls = require("src.core.TouchControls") +local SaveData = require("src.core.SaveData") +local FilePicker = require("src.core.FilePicker") +local SafeArea = require("src.core.SafeArea") +local GamepadMap = require("src.core.GamepadMap") +local PadCursor = require("src.ui.PadCursor") + +local Studio = {} +-- Own native touch callbacks on both mobile platforms; synthesized mouse +-- events from that same finger must not activate editor controls twice. +Studio.touchFirst = true + +Studio.CANVASES = { + { id = "phone_portrait", label = "Phone portrait", w = 1080, h = 1920 }, + { id = "phone_landscape", label = "Phone landscape", w = 1920, h = 1080 }, + { id = "this_device", label = "This screen", live = true }, + { id = "tablet_portrait", label = "Tablet portrait", w = 1536, h = 2048 }, + { id = "tablet_landscape", label = "Tablet landscape", w = 2048, h = 1536 }, + { id = "steamdeck", label = "Steam Deck", w = 1280, h = 800 }, + { id = "desktop_1080", label = "Desktop 1080p", w = 1920, h = 1080 }, + { id = "ultrawide", label = "Ultrawide 21:9", w = 2560, h = 1080 }, + { id = "sgb_border", label = "Super Game Boy border", w = 256, h = 224, + lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 224 } }, +} + +-- Editor view zoom: 1 is contain-fit. Smaller values shrink the mock +-- device inside the workspace so the screen hole can be dragged past the +-- bezel and you can still grab the handles. +Studio.ZOOM_LEVELS = { 1, 0.75, 0.5, 0.35 } +Studio.viewZoom = 1 + +-- Hydrated live canvas; canvas() is called many times a frame so this is +-- mutated in place instead of allocating a new table. +local liveCanvas = { id = "this_device", label = "This screen", live = true, + w = 1080, h = 1920 } + +-- Per-page lock, and whether the canvas preset follows it. Match is on +-- by default so a portrait/landscape overlay pair does not need two +-- separate clicks to preview the right way up. #1503 +Studio.matchOrient = true +Studio.ORIENT_CYCLE = { "any", "portrait", "landscape" } +Studio.ORIENT_LABEL = { + any = "Lock: Off", + portrait = "Lock: Portrait", + landscape = "Lock: Landscape", +} + +local HANDLE = 7 +local TAP_SLOP2 = 16 * 16 +local HANDLES = { + { "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 }, + { "w", 0, 0.5 }, { "e", 1, 0.5 }, + { "sw", 0, 1 }, { "s", 0.5, 1 }, { "se", 1, 1 }, +} + +local GB_ASPECT = 160 / 144 + +Studio.UNDO_CAP = 50 +Studio.SNAP_PX = 4 +Studio.STATUS_HOLD = 4 + +Studio.FORMAT_LABEL = { + native = "RetroRecompiled", + retroarch = "RetroArch", + delta = "Delta", +} + +Studio.EXPORTS = { + { id = "native", label = "RetroRecompiled .zip", + hint = "Reopens here and in the launcher." }, + { id = "retroarch", label = "RetroArch .zip", + hint = "An overlay .cfg RetroArch can load." }, + { id = "delta", label = "Delta .deltaskin", + hint = "info.json plus art, for Delta and Ignited." }, +} + +Studio.BIND_GROUPS = { + { title = "GAME BOY", + specs = { "a", "b", "start", "select", "up", "down", "left", "right" } }, + { title = "GBA / SHOULDERS", + specs = { "l", "r", "l|r" } }, + { title = "NATIVE DS BUTTONS", specs = { "x", "y" } }, + { title = "DIAGONALS", + specs = { "left|up", "right|up", "left|down", "right|down" } }, + { title = "HOTKEYS", + specs = { "menu_toggle", "reset", "hold_fast_forward", + "toggle_fast_forward", "overlay_next", "overlay_previous", + "pause_toggle", "screenshot", "exit_emulator" } }, + { title = "KEYBOARD", + specs = { "key:escape", "key:return", "key:space", "key:tab", "key:f1" } }, + -- These are deliberately keyboard binds rather than new skin-only actions. + -- They therefore take the exact same path as their desktop counterparts in + -- Game:keypressed, including any future changes to those shortcuts. + { title = "DESKTOP HOTKEYS", + specs = { "key:-", "key:=", "key:1", "key:2", "key:3", "key:4", + "key:5", "key:f1", "key:f2", "key:f10" } }, + { title = "NO INPUT", specs = { "nul" } }, +} + +Studio.BIND_PARTS = { "left", "right", "up", "down", "a", "b", "l", "r", "start", "select", "x", "y" } + +local PART_RANK = { + left = 1, right = 2, up = 3, down = 4, + a = 5, b = 6, l = 7, r = 8, start = 9, select = 10, + x = 11, y = 12, +} + +local function now() + if love and love.timer and love.timer.getTime then return love.timer.getTime() end + return 0 +end + +local function clamp01(v) + if v ~= v then return 0 end + if v < 0 then return 0 end + if v > 1 then return 1 end + return v +end + +local function round(v) return math.floor(v + 0.5) end + +function Studio.deviceSize() + if love and love.graphics and love.graphics.getDimensions then + local w, h = love.graphics.getDimensions() + w, h = tonumber(w), tonumber(h) + if w and h and w > 1 and h > 1 then return w, h end + end + return 1080, 1920 +end + +function Studio.canvasIndexById(id) + for i, c in ipairs(Studio.CANVASES) do + if c.id == id then return i end + end + return nil +end + +function Studio.canvas() + local spec = Studio.CANVASES[Studio.canvasIndex] or Studio.CANVASES[1] + if spec.live then + local w, h = Studio.deviceSize() + liveCanvas.id = spec.id + liveCanvas.label = spec.label + liveCanvas.w, liveCanvas.h = w, h + liveCanvas.live = true + liveCanvas.lockViewport = spec.lockViewport + return liveCanvas + end + return spec +end + +function Studio.zoomIndex() + local z = Studio.viewZoom or 1 + local best, dist = 1, math.huge + for i, v in ipairs(Studio.ZOOM_LEVELS) do + local d = math.abs(v - z) + if d < dist then best, dist = i, d end + end + return best +end + +function Studio.zoomOut() + local i = Studio.zoomIndex() + if i < #Studio.ZOOM_LEVELS then + Studio.viewZoom = Studio.ZOOM_LEVELS[i + 1] + end + return Studio.viewZoom +end + +function Studio.zoomIn() + local i = Studio.zoomIndex() + if i > 1 then + Studio.viewZoom = Studio.ZOOM_LEVELS[i - 1] + end + return Studio.viewZoom +end + +function Studio.zoomFit() + Studio.viewZoom = Studio.ZOOM_LEVELS[1] + return Studio.viewZoom +end + +function Studio.detectDeviceCanvas() + local idx = Studio.canvasIndexById("this_device") + if not idx then return nil end + Studio.setCanvas(idx) + local canvas = Studio.canvas() + Studio.setStatus(("This screen: %dx%d"):format(canvas.w, canvas.h)) + return canvas +end + +-- Usable chrome rect. Background still fills the window so the notch +-- band is the same colour as the rest of the studio; every button and +-- the mock device live inside the safe area, matching the launcher. +function Studio.safeFrame() + local W, H = 0, 0 + if love and love.graphics and love.graphics.getDimensions then + W, H = love.graphics.getDimensions() + end + W, H = math.max(1, tonumber(W) or 1), math.max(1, tonumber(H) or 1) + local ox, oy, sw, sh = SafeArea.rect() + ox = math.max(0, tonumber(ox) or 0) + oy = math.max(0, tonumber(oy) or 0) + sw = math.max(1, tonumber(sw) or W) + sh = math.max(1, tonumber(sh) or H) + Studio._frame = { W = W, H = H, x = ox, y = oy, w = sw, h = sh } + return W, H, ox, oy, sw, sh +end + +function Studio.page() + local skin = Studio.skin + if not skin then return nil end + return skin.pages[Studio.pageIndex] or skin.pages[1] +end + +function Studio.selectedControl() + local page = Studio.page() + if not page then return nil end + return page.controls[Studio.selected] +end + +local function setStatus(text, isError) + Studio.status = text + Studio.statusErr = isError == true + Studio.statusAt = now() +end +Studio.setStatus = setStatus + +function Studio.expireStatus() + if not Studio.statusErr or not Studio.status then return end + if now() - (Studio.statusAt or 0) > Studio.STATUS_HOLD then + Studio.status, Studio.statusErr = nil, false + end +end + +local function markDirty() + Studio.dirty = true + if not Studio.statusErr then Studio.status = nil end +end + +local function snapshot() + return { + skin = TouchSkin.clone(Studio.skin), + pageIndex = Studio.pageIndex, + selected = Studio.selected, + idField = Studio.skinIdField, + } +end + +local function restore(snap) + Studio.skin = snap.skin + Studio.pageIndex = snap.pageIndex or 1 + Studio.selected = snap.selected + Studio.skinIdField = snap.idField or Studio.skinIdField + TouchSkin.setActive(Studio.skin) + TouchSkin.pageIndex = Studio.pageIndex + Studio.dirty = true +end + +function Studio.pushUndo(tag) + if not Studio.skin then return false end + Studio.undoStack = Studio.undoStack or {} + Studio.redoStack = {} + if tag and Studio.undoTag == tag and now() - (Studio.undoAt or 0) < 1 then + return false + end + Studio.undoTag, Studio.undoAt = tag, now() + local stack = Studio.undoStack + stack[#stack + 1] = snapshot() + while #stack > Studio.UNDO_CAP do table.remove(stack, 1) end + return true +end + +function Studio.undo() + local stack = Studio.undoStack or {} + local snap = stack[#stack] + if not snap then + setStatus("Nothing to undo.") + return false + end + table.remove(stack) + Studio.redoStack = Studio.redoStack or {} + Studio.redoStack[#Studio.redoStack + 1] = snapshot() + Studio.undoTag = nil + restore(snap) + setStatus("Undo") + return true +end + +function Studio.redo() + local stack = Studio.redoStack or {} + local snap = stack[#stack] + if not snap then + setStatus("Nothing to redo.") + return false + end + table.remove(stack) + Studio.undoStack = Studio.undoStack or {} + Studio.undoStack[#Studio.undoStack + 1] = snapshot() + Studio.undoTag = nil + restore(snap) + setStatus("Redo") + return true +end + +function Studio.canUndo() return #(Studio.undoStack or {}) > 0 end +function Studio.canRedo() return #(Studio.redoStack or {}) > 0 end + +function Studio.openModal(kind, data) + data = data or {} + data.kind = kind + data.scroll = 0 + Studio.modal = data + return data +end + +function Studio.closeModal() + Studio.modal = nil + Kit.blur() +end + +function Studio.modalUp() + return Studio.modal ~= nil or Studio.confirm ~= nil +end + +function Studio.ask(text, onYes, yesLabel) + Studio.confirm = { text = text, onYes = onYes, + yesLabel = yesLabel or "Discard" } + return Studio.confirm +end + +function Studio.confirmYes() + local c = Studio.confirm + Studio.confirm = nil + if c and c.onYes then c.onYes() end + return c ~= nil +end + +function Studio.confirmNo() + local had = Studio.confirm ~= nil + Studio.confirm = nil + return had +end + +function Studio.guard(text, fn) + if not Studio.dirty or not Studio.skin then + fn() + return true + end + Studio.ask(text, fn) + return false +end + +local function syncActive() + TouchSkin.setActive(Studio.skin) + TouchSkin.pageIndex = Studio.pageIndex +end + +local function canvasOrientation(canvas) + canvas = canvas or Studio.canvas() + if not canvas then return nil end + local w, h = canvas.w, canvas.h + if canvas.live and (not w or not h) then + w, h = Studio.deviceSize() + end + if not w or not h then return nil end + return w > h and "landscape" or "portrait" +end + +local function pickCanvasIndex(want) + local cur = Studio.canvas() + -- A live "this screen" canvas already is the window; keep it so Match + -- canvas cannot yank a phone author back onto a generic 16:9 preset. + if cur and cur.live then return Studio.canvasIndex end + if canvasOrientation(cur) == want then return Studio.canvasIndex end + if cur and cur.id then + local hint = cur.id:gsub("portrait", want):gsub("landscape", want) + for i, c in ipairs(Studio.CANVASES) do + if c.id == hint then return i end + end + end + for i, c in ipairs(Studio.CANVASES) do + if not c.live and not c.lockViewport + and canvasOrientation(c) == want then + return i + end + end + return nil +end + +function Studio.applyImportedOrient() + if not Studio.skin then return false end + if not TouchSkin.hasOrientPair(Studio.skin) then + Studio.syncCanvasToPage() + return false + end + -- A RetroArch overlay that already auto-rotates should do the same in + -- the studio: lock is on, canvas follows, and the visible page matches + -- the mock device. #1503 + Studio.matchOrient = true + Studio.syncPageToCanvas() + Studio.syncCanvasToPage() + return true +end + +function Studio.syncCanvasToPage() + if not Studio.matchOrient then return end + local want = TouchSkin.pageOrient(Studio.page()) + if not want then return end + local idx = pickCanvasIndex(want) + if idx and idx ~= Studio.canvasIndex then Studio.setCanvas(idx, true) end +end + +function Studio.syncPageToCanvas() + if not Studio.matchOrient or not Studio.skin then return end + local want = canvasOrientation() + if TouchSkin.pageOrient(Studio.page()) == want then return end + for i, page in ipairs(Studio.skin.pages or {}) do + if TouchSkin.pageOrient(page) == want then + Studio.pageIndex = i + Studio.selected = nil + syncActive() + return + end + end +end + +function Studio.cyclePageOrient(dir) + local page = Studio.page() + if not page then return end + local cur = TouchSkin.pageOrient(page) or "any" + local idx = 1 + for i, o in ipairs(Studio.ORIENT_CYCLE) do + if o == cur then idx = i break end + end + local n = #Studio.ORIENT_CYCLE + local nxt = Studio.ORIENT_CYCLE[((idx - 1 + (dir or 1)) % n) + 1] + page.orient = nxt + local name = tostring(page.name or "") + if (nxt == "portrait" or nxt == "landscape") + and (name == "" or name == "main" or name:match("^page%d+$")) then + page.name = nxt + end + markDirty() + Studio.syncCanvasToPage() + return nxt +end + +function Studio.setCanvas(index, fromSync) + local n = #Studio.CANVASES + Studio.canvasIndex = ((index - 1) % n) + 1 + -- Pick the matching page before writing canvas-owned fields onto it, + -- so a landscape preset does not stamp a portrait page. #1503 + if not fromSync then Studio.syncPageToCanvas() end + local canvas = Studio.canvas() + local page = Studio.page() + if page and canvas.lockViewport then + page.viewport = { + x = canvas.lockViewport.x, y = canvas.lockViewport.y, + w = canvas.lockViewport.w, h = canvas.lockViewport.h, + } + page.viewportFill = false + markDirty() + end + -- A cfg-authored aspect_ratio is the overlay's design aspect, and bezel art + -- is its own design canvas (TouchSkin.applyImageAspect); keep either so the + -- preview lays the page out exactly the way the game does (#1503). + if page and not (page.aspectFromCfg or page.aspectFromImage) then + page.aspect = canvas.w / canvas.h + end +end + +function Studio.load(opts) + opts = opts or {} + Studio._activeSkinId = nil + Studio.onClose = opts.onClose + Studio.onPlay = opts.onPlay + Studio.version = opts.version + Studio.pageIndex = 1 + Studio.selected = nil + Studio.testing = false + Studio.dirty = false + Studio.status = nil + Studio.drag = nil + Studio.pendingPlay = false + Studio.canvasIndex = 1 + Studio.viewZoom = 1 + -- On a phone the mock device should be this screen's form factor, not a + -- generic 1080x1920 16:9, so the hole and the pad land where they will + -- at play time. + if Studio.isMobile() then + local live = Studio.canvasIndexById("this_device") + if live then Studio.canvasIndex = live end + end + -- A free-form screen is the default. 10:9 is an optional convenience, + -- never a restriction on imported or custom skins. + Studio.aspectLock = false + Studio.skinIdField = "" + Studio.available = TouchSkin.list() + Studio.availableMeta = {} + Studio.imageTarget = "idle" + Studio.undoStack, Studio.redoStack = {}, {} + Studio.undoTag, Studio.undoAt = nil, nil + Studio.modal, Studio.confirm = nil, nil + Studio.guides = nil + Studio.showLabels = true + Studio.statusErr = false + Studio.thumbs = {} + Studio.pointerX, Studio.pointerY = nil, nil + Studio.pointerDown, Studio.touchId = false, nil + PadCursor.reset() + -- Studio always opens on the library. Creating and choosing a skin are + -- first-class tasks, not controls buried inside the editor workspace. + Studio.mode = "library" + Studio.libraryThumbs = {} + Studio.libraryScroll, Studio.libraryScrollMax = 0, 0 + Studio.libraryPress, Studio.libraryRects = nil, {} + Studio.libraryFocusId = nil + + TouchControls:init() + TouchControls.active = true + TouchControls.enabled = true + TouchControls:setPreview(true) + -- Play snaps pages from the window aspect. The studio uses its own + -- Match canvas toggle against the mock device instead. #1503 + TouchSkin.autoOrient = false + Studio.matchOrient = true + + local start = opts.skinId + if not start then + local saved = SaveData.loadOptions() + local tc = type(saved.touchControls) == "table" and saved.touchControls or {} + start = tc.skin + end + if start and TouchSkin.find(start) then + Studio.open(start) + else + Studio.skin = TouchSkin.newSkin("new_skin") + Studio.skinIdField = Studio.skin.id + syncActive() + end +end + +function Studio.open(id) + local entry = TouchSkin.find(id) + if not entry then return false end + local loaded = TouchSkin.load(entry.root, entry.id) + if not loaded then return false end + Studio.skin = TouchSkin.clone(loaded) + Studio.skinIdField = Studio.skin.id + Studio.pageIndex = 1 + Studio.selected = nil + Studio.dirty = false + Studio.images = TouchSkin.listImages(Studio.skin.root) + Studio.undoStack, Studio.redoStack = {}, {} + Studio.undoTag = nil + Studio.thumbs = {} + Studio.libraryThumbs = {} + syncActive() + Studio.applyImportedOrient() + return true +end + +function Studio.newSkin() + Studio.pushUndo() + Studio.skin = TouchSkin.newSkin("new_skin") + Studio.skinIdField = "new_skin" + Studio.pageIndex, Studio.selected = 1, nil + Studio.images = {} + Studio.thumbs = {} + Studio.libraryThumbs = {} + syncActive() + markDirty() +end + +function Studio.skinFormat(skin) + skin = skin or Studio.skin + local fmt = skin and skin.format or "native" + return Studio.FORMAT_LABEL[fmt] or fmt +end + +function Studio.describeSkin(entry) + Studio.availableMeta = Studio.availableMeta or {} + local meta = Studio.availableMeta[entry.id] + if meta then return meta end + local skin = TouchSkin.load(entry.root, entry.id) + local page = skin and skin.pages[1] + local controls = 0 + for _, ctl in ipairs(page and page.controls or {}) do + if not ctl.decorative then controls = controls + 1 end + end + meta = { + id = entry.id, + source = entry.source, + format = Studio.skinFormat(skin), + pages = skin and #skin.pages or 0, + controls = controls, + ok = skin ~= nil, + } + Studio.availableMeta[entry.id] = meta + return meta +end + +function Studio.skinSummary(entry) + local meta = Studio.describeSkin(entry) + local bits = { meta.source == "user" and "installed" or "bundled", meta.format } + bits[#bits + 1] = meta.controls .. " buttons" + if meta.pages > 1 then bits[#bits + 1] = meta.pages .. " pages" end + return table.concat(bits, " \194\183 ") +end + +function Studio.forgetActiveSkin() + Studio._activeSkinId = nil +end + +function Studio.activeSkinId() + if Studio._activeSkinId == nil then + local saved = SaveData.loadOptions() + local tc = type(saved.touchControls) == "table" and saved.touchControls or {} + Studio._activeSkinId = (tc.enabled ~= false and tc.skin) or false + end + return Studio._activeSkinId or nil +end + +function Studio.refreshAvailable() + Studio.forgetActiveSkin() + Studio.available = TouchSkin.list() + Studio.availableMeta = {} + Studio.libraryThumbs = {} + Studio.libraryScroll = Kit.scrollClamp(Studio.libraryScroll or 0, + Studio.libraryScrollMax or 0) + Studio.libraryPress = nil + return Studio.available +end + +function Studio.enterEditor() + Studio.mode = "editor" + Studio.selected = nil + Studio.modal, Studio.confirm = nil, nil + Studio.drag, Studio.guides = nil, nil +end + +function Studio.backToLibrary() + Studio.guard("Return to My Skins and lose the unsaved changes?", function() + Studio.refreshAvailable() + Studio.mode = "library" + Studio.selected = nil + Studio.modal, Studio.confirm = nil, nil + Studio.drag, Studio.guides = nil, nil + end) +end + +function Studio.libraryThumb(entry) + if not entry then return nil end + Studio.libraryThumbs = Studio.libraryThumbs or {} + local cached = Studio.libraryThumbs[entry.id] + if cached ~= nil then return cached or nil end + local skin = TouchSkin.load(entry.root, entry.id) + local image = skin and skin.pages and skin.pages[1] and skin.pages[1].image + Studio.libraryThumbs[entry.id] = image or false + return image +end + +function Studio.exportEntry(id) + local entry = TouchSkin.find(id) + local skin = entry and TouchSkin.load(entry.root, entry.id) + if not skin then + setStatus("Could not read " .. tostring(id), true) + return nil + end + local path, missing = TouchSkin.export(skin) + if not path then + setStatus("Export failed: " .. tostring(missing), true) + return nil + end + Studio.lastExport = path + setStatus("Exported " .. path) + return path +end + +function Studio.selectEntry(id) + local entry = TouchSkin.find(id) + if not entry then + setStatus("Could not find " .. tostring(id), true) + return false + end + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + tc.enabled, tc.skin = true, id + opts.touchControls = tc + SaveData.saveOptions(opts) + Studio.forgetActiveSkin() + TouchControls:applyOptions(opts) + setStatus("Using " .. id) + return true +end + +function Studio.deleteEntry(id) + local entry = TouchSkin.find(id) + if not entry then return false end + if entry.source ~= "user" then + setStatus("Bundled skins cannot be deleted.", true) + return false + end + Studio.ask("Delete " .. tostring(id) .. "? This removes its artwork and cannot be undone.", + function() + local ok, err = TouchSkin.remove(id) + if not ok then + setStatus("Delete failed: " .. tostring(err), true) + return + end + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + if tc.skin == id then + tc.enabled, tc.skin = true, nil + opts.touchControls = tc + SaveData.saveOptions(opts) + Studio.forgetActiveSkin() + TouchControls:applyOptions(opts) + end + Studio.refreshAvailable() + setStatus("Deleted " .. id) + end, "Delete") + return true +end + +function Studio.importSkinBytes(name, data) + local id, note = TouchSkin.installArchive(name, data) + if not id then + setStatus("Import failed: " .. tostring(note), true) + return false + end + Studio.refreshAvailable() + Studio.open(id) + Studio.enterEditor() + setStatus("Imported " .. id) + return true +end + +function Studio.importSkinFile() + if not FilePicker.available() then + setStatus("Use a desktop file picker, or drop a skin ZIP, Delta skin or CFG onto this window.", true) + return false + end + local kind = { label = "Skin", exts = { "zip", "deltaskin", "cfg" } } + local path = FilePicker.open("Choose a skin", kind) + if not path then return false end + local name, data = FilePicker.basename(path), FilePicker.read(path) + if not data then + setStatus("Could not read " .. name, true) + return false + end + return Studio.importSkinBytes(name, data) +end + +function Studio.openLoadPicker() + return Studio.guard("Open another skin and lose the unsaved changes?", + function() + Studio.refreshAvailable() + Studio.openModal("open") + end) +end + +function Studio.loadEntry(id) + Studio.closeModal() + if not Studio.open(id) then + setStatus("Could not open " .. tostring(id), true) + return false + end + local warning = Studio.skin and Studio.skin.warnings + and Studio.skin.warnings[1] + if warning then + setStatus("Opened " .. id .. ": " .. tostring(warning), true) + else + setStatus("Opened " .. id) + end + return true +end + +function Studio.unload() + Studio._activeSkinId = nil + Studio.pendingPlay = false + TouchSkin.setSurface(nil) + TouchSkin.setActive(nil) + TouchSkin.autoOrient = true + TouchControls:setPreview(false) + TouchControls:reset() + Studio.skin = nil + Studio.onClose = nil + Studio.onPlay = nil + Studio.drag = nil + Studio.modal, Studio.confirm = nil, nil + Studio.guides = nil + Studio.undoStack, Studio.redoStack = {}, {} + Studio.pointerX, Studio.pointerY = nil, nil + Studio.pointerDown, Studio.touchId = false, nil + PadCursor.reset() +end + +-- The Studio is a real touch editor on Android and iOS. Keeping this here, +-- instead of asking the host to synthesize a mouse, makes the drag state and +-- the immediate-mode hit tests agree on the same finger position. +function Studio.isMobile() + local osName = love.system and love.system.getOS and love.system.getOS() + return osName == "Android" or osName == "iOS" +end + +function Studio.disableTouchControls() + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + tc.enabled, tc.skin = false, nil + opts.touchControls = tc + SaveData.saveOptions(opts) + Studio.forgetActiveSkin() + TouchControls:applyOptions(opts) + TouchSkin.setActive(nil) + Studio.closeModal() + setStatus("On-screen controls are off.") + return true +end + +-- --------------------------------------------------------------- editing + +function Studio.addControl() + local page = Studio.page() + if not page then return end + Studio.pushUndo() + page.controls[#page.controls + 1] = + TouchSkin.newControl("a", 0.5, 0.5, 0.16, 0.09, "radial") + Studio.selected = #page.controls + markDirty() +end + +function Studio.deleteControl() + local page = Studio.page() + if not page or not Studio.selected then return end + Studio.pushUndo() + table.remove(page.controls, Studio.selected) + Studio.selected = page.controls[Studio.selected] and Studio.selected + or (#page.controls > 0 and #page.controls or nil) + markDirty() +end + +function Studio.duplicateControl() + local page, ctl = Studio.page(), Studio.selectedControl() + if not page or not ctl then return end + Studio.pushUndo() + local copy = TouchSkin.newControl(ctl.spec, clamp01(ctl.x + 0.04), + clamp01(ctl.y + 0.04), ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape) + copy.imagePath, copy.image = ctl.imagePath, ctl.image + copy.pressedImagePath, copy.pressedImage = ctl.pressedImagePath, ctl.pressedImage + copy.rangeMod, copy.alphaMod = ctl.rangeMod, ctl.alphaMod + table.insert(page.controls, Studio.selected + 1, copy) + Studio.selected = Studio.selected + 1 + markDirty() +end + +function Studio.cycleBind(dir) + local ctl = Studio.selectedControl() + if not ctl then return end + local list, at = TouchSkin.BINDS, 1 + for i, spec in ipairs(list) do + if spec == ctl.spec then at = i break end + end + Studio.pushUndo("bind") + TouchSkin.setBind(ctl, list[((at - 1 + dir) % #list) + 1]) + markDirty() +end + +function Studio.bindParts(spec) + local out = {} + for raw in tostring(spec or ""):gmatch("[^|]+") do + local name = raw:gsub("^%s+", ""):gsub("%s+$", ""):lower() + if name ~= "" and name ~= "nul" then out[#out + 1] = name end + end + return out +end + +function Studio.hasBindPart(spec, part) + for _, name in ipairs(Studio.bindParts(spec)) do + if name == part then return true end + end + return false +end + +function Studio.toggleBindPart(spec, part) + local kept, found = {}, false + for _, name in ipairs(Studio.bindParts(spec)) do + if name == part then + found = true + else + kept[#kept + 1] = name + end + end + if not found then kept[#kept + 1] = part end + if #kept == 0 then return "nul" end + table.sort(kept, function(a, b) + local ra, rb = PART_RANK[a] or 9, PART_RANK[b] or 9 + if ra ~= rb then return ra < rb end + return a < b + end) + return table.concat(kept, "|") +end + +function Studio.setBindSpec(spec) + local ctl = Studio.selectedControl() + if not ctl or not spec then return false end + Studio.pushUndo() + TouchSkin.setBind(ctl, spec) + markDirty() + return true +end + +function Studio.toggleSelectedBindPart(part) + local ctl = Studio.selectedControl() + if not ctl then return false end + return Studio.setBindSpec(Studio.toggleBindPart(ctl.spec, part)) +end + +function Studio.openBindPicker() + if not Studio.selectedControl() then + setStatus("Select a control first.", true) + return false + end + Studio.openModal("bind") + return true +end + +function Studio.moveControlOrder(delta) + local page, i = Studio.page(), Studio.selected + if not page or not i then return false end + local j = i + delta + if j < 1 or j > #page.controls then return false end + Studio.pushUndo() + page.controls[i], page.controls[j] = page.controls[j], page.controls[i] + Studio.selected = j + markDirty() + return true +end + +function Studio.nudge(dx, dy, big) + local ctl = Studio.selectedControl() + if not ctl then return false end + local canvas = Studio.canvas() + local step = big and 10 or 1 + Studio.pushUndo("nudge") + ctl.x = clamp01(ctl.x + dx * step / canvas.w) + ctl.y = clamp01(ctl.y + dy * step / canvas.h) + markDirty() + return true +end + +function Studio.snapOffset(edges, lines, tol) + local best, at = nil, nil + for _, e in ipairs(edges) do + for _, l in ipairs(lines) do + local d = l - e + if math.abs(d) <= tol and (best == nil or math.abs(d) < math.abs(best)) then + best, at = d, l + end + end + end + return best or 0, at +end + + +function Studio.cycleImage(dir) + local ctl = Studio.selectedControl() + local page = Studio.page() + if not page then return end + Studio.images = Studio.images or TouchSkin.listImages(Studio.skin.root) + local list = Studio.images + local field = Studio.imageTarget + local owner = (field == "bezel") and page or ctl + if not owner then return end + local key = (field == "pressed") and "pressedImagePath" or "imagePath" + if field == "bezel" then key = "imagePath" end + + local at = 0 + for i, rel in ipairs(list) do + if rel == owner[key] then at = i break end + end + local next_ = at + dir + if next_ < 0 then next_ = #list end + if next_ > #list then next_ = 0 end + local rel = (next_ >= 1) and list[next_] or nil + owner[key] = rel + local img = rel and TouchSkin.resolveImage(Studio.skin.root, rel) or nil + if field == "pressed" then + owner.pressedImage = img + else + owner.image = img + if field == "bezel" then TouchSkin.applyImageAspect(owner) end + end + markDirty() +end + +function Studio.refreshImages() + Studio.images = Studio.skin and TouchSkin.listImages(Studio.skin.root) or {} + Studio.thumbs = {} + return Studio.images +end + +function Studio.openImagePicker(target) + if not Studio.skin then return false end + target = target or Studio.imageTarget + if target ~= "bezel" and not Studio.selectedControl() then + setStatus("Select a control first, or pick a bezel image.", true) + return false + end + Studio.imageTarget = target + Studio.refreshImages() + Studio.openModal("image") + return true +end + +function Studio.currentImagePath() + local target = Studio.imageTarget + if target == "bezel" then + local page = Studio.page() + return page and page.imagePath + end + local ctl = Studio.selectedControl() + if not ctl then return nil end + return (target == "pressed") and ctl.pressedImagePath or ctl.imagePath +end + +function Studio.thumb(rel) + if not rel or not Studio.skin then return nil end + Studio.thumbs = Studio.thumbs or {} + local cached = Studio.thumbs[rel] + if cached ~= nil then return cached or nil end + local img = TouchSkin.resolveImage(Studio.skin.root, rel) + Studio.thumbs[rel] = img or false + return img +end + +function Studio.chooseImage(rel) + if not Studio.skin then return false end + Studio.pushUndo() + Studio.assignImage(rel) + Studio.closeModal() + setStatus(rel and ("Using " .. rel .. " as " .. Studio.imageTargetLabel()) + or ("Cleared the " .. Studio.imageTargetLabel())) + return true +end + +function Studio.imageTargetLabel() + local target = Studio.imageTarget + if target == "bezel" or not Studio.selectedControl() then return "bezel" end + return target == "pressed" and "pressed art" or "idle art" +end + +function Studio.assignImage(rel) + local page, ctl = Studio.page(), Studio.selectedControl() + local img = TouchSkin.resolveImage(Studio.skin.root, rel) + local target = Studio.imageTarget + if ctl and target == "pressed" then + ctl.pressedImagePath, ctl.pressedImage = rel, img + elseif ctl and target == "idle" then + ctl.imagePath, ctl.image = rel, img + elseif page then + page.imagePath, page.image = rel, img + TouchSkin.applyImageAspect(page) + end + Studio.images = TouchSkin.listImages(Studio.skin.root) + Studio.dirty = true +end + +local function commitSkinId() + local skin = Studio.skin + if not skin then return end + local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "") + if id == "" or id == skin.id then return end + TouchSkin.saveTo(skin, id) + Studio.available = TouchSkin.list() +end + +function Studio.adoptImage(name, data, target) + if not Studio.skin then return false end + if target then Studio.imageTarget = target end + if not FilePicker.matches(name, FilePicker.IMAGE) then + setStatus("Pick a PNG or JPG.", true) + return false + end + commitSkinId() + local rel, err = TouchSkin.importImage(Studio.skin, name, data) + if not rel then + setStatus("Import failed: " .. tostring(err), true) + return false + end + local where = Studio.imageTargetLabel() + Studio.pushUndo() + Studio.assignImage(rel) + Studio.skinIdField = Studio.skin.id + local text = "Imported " .. rel .. " as " .. where + if where == "bezel" and not Studio.canvas().lockViewport then + text = text .. " -- use Detect screen from bezel to place the screen" + end + setStatus(text) + return true +end + +function Studio.importImageFile(target) + if not Studio.skin then return end + target = target or Studio.imageTarget + Studio.imageTarget = target + if target ~= "bezel" and not Studio.selectedControl() then + setStatus("Select a control first, or import a bezel image.", true) + return + end + if not FilePicker.available() then + setStatus("No file picker here -- drag a PNG onto the window instead.", true) + return + end + local prompt = (target == "bezel") and "Choose a bezel image" + or "Choose a button image" + local path = FilePicker.open(prompt, FilePicker.IMAGE) + if not path then return end + local base = FilePicker.basename(path) + local data, err = FilePicker.read(path) + if not data then + setStatus("Could not read " .. base .. ": " .. tostring(err), true) + return + end + Studio.adoptImage(base, data, target) +end + +function Studio.filedropped(file) + if not Studio.skin then return end + local path = (file.getFilename and file:getFilename()) or "" + local base = FilePicker.basename(path) + local archive=FilePicker.matches(base,{exts={'zip','deltaskin','cfg'}}) + if not archive and not FilePicker.matches(base, FilePicker.IMAGE) then + setStatus("Drop a PNG/JPG for art, or a ZIP/Delta/CFG skin.", true) + return + end + local ok, data = pcall(function() + file:open("r") + local bytes = file:read() + file:close() + return bytes + end) + if not ok or not data then + setStatus("Could not read " .. base, true) + return + end + if archive then + return Studio.guard('Import another skin and lose the unsaved changes?', + function() Studio.importSkinBytes(base,data) end) + end + return Studio.adoptImage(base, data) +end + +function Studio.detectViewport() + local page = Studio.page() + if not page or not Studio.skin then return end + if Studio.canvas().lockViewport then + setStatus("This preset locks the screen position.", true) + return + end + if not page.imagePath then + setStatus("Pick a bezel image first.", true) + return + end + local rect, pw, ph = TouchSkin.detectViewport(Studio.skin.root, page.imagePath) + if not rect then + setStatus("No transparent screen hole found in " .. page.imagePath, true) + return + end + Studio.pushUndo() + page.viewport = rect + page.screenFit = nil + setStatus(("Screen detected: %dx%d px in the bezel art"):format(pw, ph)) + Studio.dirty = true +end + +function Studio.toggleViewport() + local page = Studio.page() + if not page then return end + if Studio.canvas().lockViewport then return end + Studio.pushUndo() + if page.viewport or page.screenFit == "remainder" then + page.viewport = nil + page.screenFit = nil + else + page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 } + end + markDirty() +end + +function Studio.setPage(index) + local skin = Studio.skin + if not skin or not skin.pages[index] then return false end + Studio.pageIndex = index + Studio.selected = nil + syncActive() + Studio.syncCanvasToPage() + return true +end + +function Studio.nextPage() + local skin = Studio.skin + if not skin or #skin.pages == 0 then return false end + return Studio.setPage((Studio.pageIndex % #skin.pages) + 1) +end + +function Studio.pageLabel(index) + local skin = Studio.skin + local page = skin and skin.pages[index] + if not page then return "" end + local name = tostring(page.name or ("page" .. index)) + local orient = TouchSkin.pageOrient(page) + local bits = { #(page.controls or {}) .. " controls" } + if orient then bits[#bits + 1] = orient end + if page.viewport or page.screenFit == "remainder" then bits[#bits + 1] = "screen" end + return name, table.concat(bits, " \194\183 ") +end + +function Studio.openPageMenu() + local skin = Studio.skin + if not skin then return false end + Studio.pageNameField = tostring(Studio.page() and Studio.page().name or "") + Studio.openModal("page") + return true +end + +function Studio.openScreenMenu() + Studio.openModal("screen") + return true +end + +function Studio.renamePage(name) + local page = Studio.page() + if not page then return false end + name = tostring(name or ""):gsub("^%s+", ""):gsub("%s+$", "") + if name == "" then + setStatus("Give the page a name first.", true) + return false + end + Studio.pushUndo() + page.name = name + markDirty() + setStatus("Renamed page " .. Studio.pageIndex .. " to " .. name) + return true +end + +function Studio.deletePage(index) + local skin = Studio.skin + index = index or Studio.pageIndex + if not skin or not skin.pages[index] then return false end + if #skin.pages <= 1 then + setStatus("A skin needs at least one page.", true) + return false + end + Studio.pushUndo() + table.remove(skin.pages, index) + for i, page in ipairs(skin.pages) do page.index = i end + Studio.pageIndex = math.min(Studio.pageIndex, #skin.pages) + Studio.selected = nil + syncActive() + markDirty() + setStatus("Deleted a page") + return true +end + +function Studio.addPage() + local skin = Studio.skin + if not skin then return end + Studio.pushUndo() + local page = TouchSkin.newSkin(skin.id).pages[1] + page.name = "page" .. (#skin.pages + 1) + page.index = #skin.pages + 1 + skin.pages[#skin.pages + 1] = page + Studio.pageIndex = #skin.pages + Studio.selected = nil + syncActive() + Studio.syncCanvasToPage() + markDirty() +end + +function Studio.save() + local skin = Studio.skin + if not skin then return end + local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "") + if id == "" then + setStatus("Give the skin a name first.", true) + return + end + local dest, failed = TouchSkin.saveTo(skin, id) + if not dest then + setStatus("Save failed: " .. tostring(failed), true) + return + end + Studio.dirty = false + Studio.refreshAvailable() + local text = "Saved to " .. dest + if type(failed) == "table" and failed[1] then + text = text .. " (" .. #failed .. " image(s) not found)" + end + setStatus(text) +end + +function Studio.exportAs(kind) + local skin = Studio.skin + if not skin then return nil end + if Studio.dirty then Studio.save() end + if Studio.dirty then return nil end + local path, missing, warnings + if kind == "retroarch" then + path, missing = TouchSkin.exportRetroArch(skin) + elseif kind == "delta" then + path, missing, warnings = TouchSkin.exportDelta(skin) + else + path, missing = TouchSkin.export(skin) + end + if not path then + setStatus("Export failed: " .. tostring(missing), true) + return nil + end + local text = "Exported " .. path + if type(missing) == "table" and missing[1] then + text = text .. " (" .. #missing .. " image(s) not found)" + end + if type(warnings) == "table" and warnings[1] then + text = text .. " " .. tostring(warnings[1]) + end + setStatus(text) + Studio.lastExport = path + Studio.refreshAvailable() + return path +end + +function Studio.export() + return Studio.exportAs("native") +end + +function Studio.openExportMenu() + Studio.openModal("export") + return true +end + +function Studio.fileUrl(path) + path = tostring(path):gsub("\\", "/") + if path:sub(1, 1) ~= "/" then path = "/" .. path end + local encoded = path:gsub("[^%w%-%._~/:]", function(c) + return string.format("%%%02X", string.byte(c)) + end) + return "file://" .. encoded +end + +function Studio.revealExport() + local path = Studio.lastExport + if not path then return false end + if not (love and love.filesystem and love.filesystem.getSaveDirectory) then + return false + end + local dir = love.filesystem.getSaveDirectory() + if not dir then return false end + if love.system and love.system.openURL then + pcall(love.system.openURL, Studio.fileUrl(dir)) + end + setStatus("Saved in " .. dir .. "/" .. path) + return true +end + +function Studio.play() + local skin = Studio.skin + if not skin then return end + Studio.save() + if Studio.dirty then return end + local opts = SaveData.loadOptions() + local block = type(opts.touchControls) == "table" and opts.touchControls or {} + block.enabled = true + block.skin = skin.id + opts.touchControls = block + SaveData.saveOptions(opts) + Studio.forgetActiveSkin() + -- Handing off here would unload the studio inside its own draw pass and + -- leave the rest of this frame drawing against a dead skin; Studio.update + -- runs it on the next tick instead. + Studio.pendingPlay = true + setStatus("Starting the game with " .. skin.id .. "...") +end + +-- ---------------------------------------------------------------- canvas + +function Studio.canvasRect(x, y, w, h) + local canvas = Studio.canvas() + local aspect = canvas.w / canvas.h + if not aspect or aspect <= 0 then aspect = 1 end + local cw, ch = w, w / aspect + if ch > h then ch, cw = h, h * aspect end + local z = Studio.viewZoom or 1 + if z < 0.999 then + cw, ch = cw * z, ch * z + end + return x + (w - cw) * 0.5, y + (h - ch) * 0.5, cw, ch +end + +local function controlRect(page, ctl, r) + local cx, cy, halfW, halfH = + TouchSkin.controlGeometry(page, ctl, r.w, r.h, r.x, r.y) + return cx - halfW, cy - halfH, halfW * 2, halfH * 2 +end + +function Studio.snapLines(page, r, skipIndex) + local xs, ys = {}, {} + local px, py, pw, ph = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y) + xs[1], xs[2], xs[3] = px, px + pw * 0.5, px + pw + ys[1], ys[2], ys[3] = py, py + ph * 0.5, py + ph + for i, ctl in ipairs(page.controls or {}) do + if i ~= skipIndex then + local bx, by, bw, bh = controlRect(page, ctl, r) + xs[#xs + 1] = bx + xs[#xs + 1] = bx + bw * 0.5 + xs[#xs + 1] = bx + bw + ys[#ys + 1] = by + ys[#ys + 1] = by + bh * 0.5 + ys[#ys + 1] = by + bh + end + end + return xs, ys +end + +local function viewportRect(page, r) + local x, y, w, h = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y) + if not x then return nil end + return x, y, w, h +end + +local function handleRects(bx, by, bw, bh) + local out = {} + for _, h in ipairs(HANDLES) do + out[#out + 1] = { + id = h[1], + x = bx + bw * h[2] - HANDLE * Kit.scale, + y = by + bh * h[3] - HANDLE * Kit.scale, + w = HANDLE * 2 * Kit.scale, h = HANDLE * 2 * Kit.scale, + } + end + return out +end + +local function applyResize(id, bx, by, bw, bh, dx, dy) + if id:find("w") then bx = bx + dx bw = bw - dx end + if id:find("e") then bw = bw + dx end + if id:find("n") then by = by + dy bh = bh - dy end + if id:find("s") then bh = bh + dy end + return bx, by, math.max(4, bw), math.max(4, bh) +end + +function Studio.beginCanvasDrag(mx, my, r) + local page = Studio.page() + if not page then return end + Studio.guides = nil + + local ctl = Studio.selectedControl() + if ctl then + local bx, by, bw, bh = controlRect(page, ctl, r) + for _, h in ipairs(handleRects(bx, by, bw, bh)) do + if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then + Studio.pushUndo() + Studio.drag = { kind = "control-resize", handle = h.id, mx = mx, my = my, + bx = bx, by = by, bw = bw, bh = bh } + return + end + end + end + + local vx, vy, vw, vh = viewportRect(page, r) + if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then + for _, h in ipairs(handleRects(vx, vy, vw, vh)) do + if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then + Studio.pushUndo() + Studio.drag = { kind = "viewport-resize", handle = h.id, mx = mx, my = my, + bx = vx, by = vy, bw = vw, bh = vh } + Studio.selected = nil + return + end + end + end + + for i = #page.controls, 1, -1 do + local c = page.controls[i] + local bx, by, bw, bh = controlRect(page, c, r) + if mx >= bx and mx <= bx + bw and my >= by and my <= by + bh then + Studio.selected = i + Studio.pushUndo() + Studio.drag = { kind = "control-move", mx = mx, my = my, + bx = bx, by = by, bw = bw, bh = bh } + return + end + end + + if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder" + and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then + Studio.selected = nil + Studio.pushUndo() + Studio.drag = { kind = "viewport-move", mx = mx, my = my, + bx = vx, by = vy, bw = vw, bh = vh } + return + end + + Studio.selected = nil +end + +function Studio.updateDrag(mx, my, r) + local d = Studio.drag + local page = Studio.page() + if not d or not page then return end + local dx, dy = mx - d.mx, my - d.my + local bx, by, bw, bh = d.bx, d.by, d.bw, d.bh + + if d.kind == "control-move" or d.kind == "viewport-move" then + bx, by = bx + dx, by + dy + else + bx, by, bw, bh = applyResize(d.handle, bx, by, bw, bh, dx, dy) + end + + if d.kind:find("viewport") and Studio.aspectLock and d.handle then + -- Derive the axis the handle does not drive, or an edge handle fights the + -- lock and appears dead. A corner drives whichever way the pointer moved + -- furthest, so dragging it up/down resizes as readily as left/right. + local byHeight = (d.handle == "n" or d.handle == "s") + if not byHeight and #d.handle > 1 then + byHeight = math.abs(dy) > math.abs(dx) + end + if byHeight then + local newW = bh * GB_ASPECT + if d.handle:find("w") then bx = bx + (bw - newW) + elseif not d.handle:find("e") then bx = bx + (bw - newW) * 0.5 end + bw = newW + else + local newH = bw / GB_ASPECT + if d.handle:find("n") then by = by + (bh - newH) + elseif not d.handle:find("s") then by = by + (bh - newH) * 0.5 end + bh = newH + end + end + + if d.kind == "control-move" then + local tol = Studio.SNAP_PX * Kit.scale + local xs, ys = Studio.snapLines(page, r, Studio.selected) + local offX, lineX = Studio.snapOffset({ bx, bx + bw * 0.5, bx + bw }, xs, tol) + local offY, lineY = Studio.snapOffset({ by, by + bh * 0.5, by + bh }, ys, tol) + bx, by = bx + offX, by + offY + Studio.guides = { x = lineX, y = lineY } + end + + local px, py, pw, ph = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y) + if pw <= 0 or ph <= 0 then return end + if d.kind:find("control") then + local ctl = Studio.selectedControl() + if not ctl then return end + ctl.x = clamp01(((bx + bw * 0.5) - px) / pw) + ctl.y = clamp01(((by + bh * 0.5) - py) / ph) + ctl.rangeX = math.max(0.002, (bw * 0.5) / pw) + ctl.rangeY = math.max(0.002, (bh * 0.5) / ph) + else + -- Permit an anchor to live beyond every canvas edge. That is useful for + -- intentional off-centre compositions and mirrors the normal Screen Pos + -- behaviour at runtime; only a non-zero size is required. + page.viewport = { + x = (bx - px) / pw, y = (by - py) / ph, + w = math.max(0.02, bw / pw), h = math.max(0.02, bh / ph), + } + end + markDirty() +end + +-- ----------------------------------------------------------------- draw + +local function drawGameTest(r, page) + -- Test letterboxes a 160x144 picture inside the screen cutout, the same + -- way gameplay fits the Game Boy surface into the hole. + local vx, vy, vw, vh = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y) + if not vx then vx, vy, vw, vh = r.x, r.y, r.w, r.h end + local s = math.min(vw / 160, vh / 144) + local gw, gh = 160 * s, 144 * s + local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5 + Theme.fill(r.x, r.y, r.w, r.h, { 8, 12, 8 }, 1) + Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1) + local tile = math.max(2, math.floor(16 * s)) + for row = 0, 8 do + for col = 0, 9 do + if (row + col) % 2 == 0 then + Theme.fill(gx + col * tile, gy + row * tile, tile, tile, + { 139, 172, 15 }, 0.45) + end + end + end + Theme.fill(gx + gw * 0.10, gy + gh * 0.15, gw * 0.22, gh * 0.18, + { 48, 98, 48 }, 0.9) + Theme.fill(gx + gw * 0.58, gy + gh * 0.49, gw * 0.12, gh * 0.18, + { 48, 98, 48 }, 0.9) + Theme.fill(gx + gw * 0.14, gy + gh * 0.70, gw * 0.72, gh * 0.16, + { 224, 248, 208 }, 1) + Kit.text("small", "TEST BATTLE", gx + gw * 0.18, gy + gh * 0.74, + { 24, 56, 24 }) +end + +local function drawCanvas(x, y, w, h) + Studio.canvasWorkspace = { x = x, y = y, w = w, h = h } + local page = Studio.page() + local r = { } + r.x, r.y, r.w, r.h = Studio.canvasRect(x, y, w, h) + Studio.lastCanvas = r + Theme.fill(x, y, w, h, { 10, 12, 18 }, 1) + if not page then return r end + + Theme.fill(r.x, r.y, r.w, r.h, { 0, 0, 0 }, 1) + + TouchSkin.setSurface(r.x, r.y, r.w, r.h) + syncActive() + + local vx, vy, vw, vh = viewportRect(page, r) + if Studio.testing then + drawGameTest(r, page) + elseif vx then + local scale = math.min(vw / 160, vh / 144) + local gw, gh = 160 * scale, 144 * scale + local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5 + Theme.fill(vx, vy, vw, vh, { 12, 12, 12 }, 1) + Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1) + Kit.textCenter("small", "160 x 144", gx, gy + gh * 0.5 - Kit.textHeight("small") * 0.5, + gw, { 20, 40, 20 }) + end + + TouchControls:draw() + TouchSkin.setSurface(nil) + + Theme.strokeRounded(r.x, r.y, r.w, r.h, PAL.line, Theme.A.hairline, 1, 2) + + if Studio.testing then return r end + + if vx then + Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2) + Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue) + if not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then + local a = Studio.selectedControl() and 0.45 or 1 + for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do + Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a) + end + end + end + + for i, ctl in ipairs(page.controls) do + local bx, by, bw, bh = controlRect(page, ctl, r) + local selected = (i == Studio.selected) + local c = ctl.decorative and PAL.muted or (selected and PAL.green or PAL.line) + Theme.strokeRounded(bx, by, bw, bh, c, selected and 1 or 0.45, + selected and 2 or 1, ctl.shape == "radial" and bh * 0.5 or 2) + if Studio.showLabels and bw > 8 * Kit.scale then + Kit.textCenter("micro", Kit.ellipsize("micro", ctl.spec, bw), bx, + by + bh * 0.5 - Kit.textHeight("micro") * 0.5, bw, + selected and PAL.green or PAL.muted) + end + if selected then + for _, hd in ipairs(handleRects(bx, by, bw, bh)) do + Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.green, 1) + end + Kit.text("small", ctl.spec, bx, by - Kit.textHeight("small") - 2 * Kit.scale, + PAL.green) + end + end + + local guides = Studio.drag and Studio.guides + if guides then + if guides.x then + Theme.fill(guides.x, r.y, math.max(1, Kit.scale), r.h, PAL.yellow, 0.6) + end + if guides.y then + Theme.fill(r.x, guides.y, r.w, math.max(1, Kit.scale), PAL.yellow, 0.6) + end + end + return r +end + +local function inspectorBody(x, y, w) + if not Studio.skin then return y end + local page = Studio.page() + local ctl = Studio.selectedControl() + local rowH = math.max(Kit.tapMin(), 30 * Kit.scale) + local gap = 6 * Kit.scale + local cy = y + + Kit.caption(x, cy, "SKIN") + cy = cy + Kit.textHeight("small") + gap + Studio.skinIdField = Kit.textfield("skinid", x, cy, w, rowH, + Studio.skinIdField, "skin name") + cy = cy + rowH + gap + + local third = (w - gap * 2) / 3 + if Kit.button(x, cy, third, rowH, "Save", { id = "save" }) then Studio.save() end + if Kit.button(x + third + gap, cy, third, rowH, "Export >", + { id = "export" }) then + Studio.openExportMenu() + end + if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Play", { id = "play" }) then + Studio.play() + end + cy = cy + rowH + 2 * Kit.scale + Kit.text("small", "Format: " .. Studio.skinFormat(), x, cy, PAL.faint) + cy = cy + Kit.textHeight("small") + gap * 2 + + Kit.caption(x, cy, "OPEN") + cy = cy + Kit.textHeight("small") + gap + local half = (w - gap) / 2 + if Kit.button(x, cy, half, rowH, "New", { id = "new" }) then + Studio.guard("Start a new skin and lose the unsaved changes?", + Studio.newSkin) + end + if Kit.button(x + half + gap, cy, half, rowH, + "Load " .. (#Studio.available > 0 and "\226\150\184" or "-"), + { id = "load", enabled = #Studio.available > 0 }) then + Studio.openLoadPicker() + end + cy = cy + rowH + gap * 2 + + Kit.caption(x, cy, "PAGE " .. Studio.pageIndex .. " / " .. #(Studio.skin.pages or {})) + cy = cy + Kit.textHeight("small") + gap + if Kit.button(x, cy, half, rowH, + "Pages \226\150\184 " .. tostring(page and page.name or "-"), + { id = "pagelist" }) then + Studio.openPageMenu() + end + if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then + Studio.addPage() + end + cy = cy + rowH + gap + local lock = TouchSkin.pageOrient(page) or "any" + if Kit.button(x, cy, half, rowH, Studio.ORIENT_LABEL[lock] or "Lock: Off", + { id = "orient" }) then + Studio.cyclePageOrient(1) + end + local matchOn = Studio.matchOrient + Studio.matchOrient = Kit.checkbox(x + half + gap, cy, half, rowH, + Studio.matchOrient, "Match canvas", "matchorient") + if Studio.matchOrient and not matchOn then Studio.syncCanvasToPage() end + cy = cy + rowH + gap + + if page then + local bezel = page.imagePath or page.rasterName or "(none)" + local pickW = 82 * Kit.scale + local cycleW = w - pickW - gap + if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then + Studio.openImagePicker("bezel") + end + if Kit.button(x + cycleW + gap, cy, pickW, rowH, "Import", + { id = "bezelpick" }) then + Studio.importImageFile("bezel") + end + cy = cy + rowH + gap + local vpLabel = (page.viewport or page.screenFit == "remainder") + and "Screen cutout: ON" or "Screen cutout: OFF" + if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp", + enabled = not Studio.canvas().lockViewport }) then + Studio.toggleViewport() + end + Studio.aspectLock = Kit.checkbox(x + half + gap, cy, half, rowH, + Studio.aspectLock, "10:9 lock", "aspect") + cy = cy + rowH + gap + if Kit.button(x, cy, w, rowH, "Detect screen from bezel", { id = "detect", + enabled = page.imagePath ~= nil and not Studio.canvas().lockViewport }) then + Studio.detectViewport() + end + cy = cy + rowH + gap * 2 + end + + Kit.caption(x, cy, "CONTROLS") + cy = cy + Kit.textHeight("small") + gap + if Kit.button(x, cy, third, rowH, "Add", { id = "add" }) then Studio.addControl() end + if Kit.button(x + third + gap, cy, third, rowH, "Dup", + { id = "dup", enabled = ctl ~= nil }) then + Studio.duplicateControl() + end + if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Del", + { id = "del", enabled = ctl ~= nil }) then + Studio.deleteControl() + end + cy = cy + rowH + gap + + if not ctl then + Kit.textWrapped("small", page and #page.controls == 0 + and "No controls yet. Add one, then drag it on the canvas." + or "Click a control on the canvas to edit it.", x, cy, w, PAL.muted, 3) + return cy + Kit.textHeight("small") * 3 + end + + local canvas = Studio.canvas() + local zW = 68 * Kit.scale + if Kit.button(x, cy, zW, rowH, "Back", { id = "zback", + enabled = (Studio.selected or 1) > 1 }) then + Studio.moveControlOrder(-1) + end + if Kit.button(x + zW + gap, cy, zW, rowH, "Front", { id = "zfront", + enabled = Studio.selected ~= nil and page ~= nil + and Studio.selected < #page.controls }) then + Studio.moveControlOrder(1) + end + Kit.text("small", ("%d / %d"):format(Studio.selected or 0, + page and #page.controls or 0), x + (zW + gap) * 2, + cy + (rowH - Kit.textHeight("small")) * 0.5, PAL.faint) + cy = cy + rowH + gap + + if Kit.button(x, cy, w, rowH, "Bind: " .. ctl.spec, { id = "bind" }) then + Studio.openBindPicker() + end + cy = cy + rowH + 2 * Kit.scale + Kit.text("small", TouchSkin.describeBind(ctl.spec), x, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + gap + + if Kit.button(x, cy, half, rowH, "Shape: " .. ctl.shape, { id = "shape" }) then + Studio.pushUndo() + ctl.shape = ctl.shape == "radial" and "rect" or "radial" + markDirty() + end + if Kit.button(x + half + gap, cy, half, rowH, + string.format("Reach x%.2f", ctl.rangeMod), { id = "rangemod" }) then + Studio.pushUndo() + ctl.rangeMod = ctl.rangeMod >= 2 and 0.5 or (ctl.rangeMod + 0.25) + markDirty() + end + cy = cy + rowH + gap + + local quarter = (w - gap * 3) / 4 + local fields = { + { "X", round((ctl.x - ctl.rangeX) * canvas.w) }, + { "Y", round((ctl.y - ctl.rangeY) * canvas.h) }, + { "W", round(ctl.rangeX * 2 * canvas.w) }, + { "H", round(ctl.rangeY * 2 * canvas.h) }, + } + for i, f in ipairs(fields) do + local fx = x + (quarter + gap) * (i - 1) + Kit.text("small", f[1], fx, cy, PAL.faint) + local id = "num" .. f[1] + local shown = Studio.editing == id and Studio.editBuf or tostring(f[2]) + local typed = Kit.textfield(id, fx, cy + Kit.textHeight("small"), + quarter, rowH, shown, "0") + if Kit.focus == id then + Studio.editing, Studio.editBuf = id, typed + elseif Studio.editing == id then + Studio.commitField(id, Studio.editBuf) + Studio.editing, Studio.editBuf = nil, nil + end + end + cy = cy + Kit.textHeight("small") + rowH + gap + Kit.text("small", ("canvas %dx%d px"):format(canvas.w, canvas.h), x, cy, PAL.faint) + cy = cy + Kit.textHeight("small") + gap + + local pickW = 82 * Kit.scale + local artW = w - pickW - gap + local idle = ctl.imagePath or "(none)" + if Kit.button(x, cy, artW, rowH, "Idle art: " .. idle, { id = "img" }) then + Studio.openImagePicker("idle") + end + if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgpick" }) then + Studio.importImageFile("idle") + end + cy = cy + rowH + gap + local pressed = ctl.pressedImagePath or "(none)" + if Kit.button(x, cy, artW, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then + Studio.openImagePicker("pressed") + end + if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgppick" }) then + Studio.importImageFile("pressed") + end + return cy + rowH +end + +local function drawInspector(x, y, w, h) + local pad = 10 * Kit.scale + Kit.card(x, y, w, h) + + local scroll = Studio.inspectorScroll or 0 + if Kit.hit(x, y, w, h) and (Kit.wheelY or 0) ~= 0 then + scroll = scroll - Kit.wheelY * 48 * Kit.scale + end + local maxScroll = math.max(0, (Studio.inspectorH or 0) - (h - pad * 2)) + scroll = math.max(0, math.min(scroll, maxScroll)) + Studio.inspectorScroll = scroll + + Kit.pushClip(x, y, w, h) + local top = y + pad - scroll + local endY = inspectorBody(x + pad, top, w - pad * 2) or top + Studio.inspectorH = endY - top + Kit.popClip() + + if maxScroll > 0 then + local frac = h / (h + maxScroll) + local barH = math.max(24 * Kit.scale, h * frac) + local barY = y + (h - barH) * (scroll / maxScroll) + Theme.fill(x + w - 4 * Kit.scale, barY, 3 * Kit.scale, barH, PAL.line, 0.4) + end +end + +function Studio.commitField(id, text) + local ctl = Studio.selectedControl() + if not ctl then return end + local n = tonumber(text) + if not n then return end + Studio.pushUndo("field") + local canvas = Studio.canvas() + local left = (ctl.x - ctl.rangeX) * canvas.w + local top = (ctl.y - ctl.rangeY) * canvas.h + local wpx = ctl.rangeX * 2 * canvas.w + local hpx = ctl.rangeY * 2 * canvas.h + if id == "numX" then left = n + elseif id == "numY" then top = n + elseif id == "numW" then wpx = math.max(1, n) + elseif id == "numH" then hpx = math.max(1, n) end + ctl.rangeX = (wpx * 0.5) / canvas.w + ctl.rangeY = (hpx * 0.5) / canvas.h + ctl.x = clamp01((left + wpx * 0.5) / canvas.w) + ctl.y = clamp01((top + hpx * 0.5) / canvas.h) + markDirty() +end + +local function modalFrame(W, H, title, wFrac, hFrac) + local ox, oy, sw, sh, winW, winH = 0, 0, W, H, W, H + local f = Studio._frame + if f then + ox, oy, sw, sh = f.x, f.y, f.w, f.h + winW, winH = f.W, f.H + end + Theme.fill(0, 0, winW, winH, PAL.bg, 0.85) + local pad = 14 * Kit.scale + local mw = math.min(sw - pad * 2, math.max(320 * Kit.scale, (wFrac or 0.6) * sw)) + local mh = math.min(sh - pad * 2, math.max(220 * Kit.scale, (hFrac or 0.72) * sh)) + local mx = math.floor(ox + (sw - mw) * 0.5) + local my = math.floor(oy + (sh - mh) * 0.5) + Kit.card(mx, my, mw, mh) + Kit.textBold("title", title, mx + pad, my + pad * 0.7, PAL.heading) + return mx, my, mw, mh, pad +end + +local function modalFooter(mx, my, mw, mh, pad) + local bh = math.max(Kit.tapMin(), 32 * Kit.scale) + local by = my + mh - pad - bh + if Kit.button(mx + mw - pad - 110 * Kit.scale, by, 110 * Kit.scale, bh, + "Close", { id = "modal-close" }) then + Studio.closeModal() + end + return by, bh +end + +local function modalScroll(modal, x, y, w, h, contentH) + local maxScroll = Kit.scrollExtent(contentH, h) + modal.scroll = select(1, Kit.scrollWheel(modal.scroll or 0, maxScroll, + x, y, w, h)) + modal.scroll = Kit.scrollClamp(modal.scroll, maxScroll) + return modal.scroll, maxScroll +end + +local function drawBindModal(W, H) + local modal = Studio.modal + local ctl = Studio.selectedControl() + local mx, my, mw, mh, pad = modalFrame(W, H, "Choose a bind", 0.66, 0.8) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by = modalFooter(mx, my, mw, mh, pad) + local viewH = by - top - pad + local rowH = math.max(Kit.tapMin(), 30 * Kit.scale) + local gap = 6 * Kit.scale + local cols = math.max(2, math.floor((mw - pad * 2) / (150 * Kit.scale))) + local colW = (mw - pad * 2 - gap * (cols - 1)) / cols + + local contentH = modal.contentH or viewH + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local cy = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, + Kit.scrollExtent(contentH, viewH)) + local startY = cy + + Kit.caption(mx + pad, cy, "COMBINE") + cy = cy + Kit.textHeight("small") + gap + for i, part in ipairs(Studio.BIND_PARTS) do + local col = (i - 1) % cols + local px = mx + pad + col * (colW + gap) + local py = cy + math.floor((i - 1) / cols) * (rowH + gap) + local on = ctl and Studio.hasBindPart(ctl.spec, part) + if Kit.chip(px, py, colW, rowH, part, on, on and PAL.green or nil, + "bindpart-" .. part) then + Studio.toggleSelectedBindPart(part) + end + end + cy = cy + math.ceil(#Studio.BIND_PARTS / cols) * (rowH + gap) + gap + + for _, group in ipairs(Studio.BIND_GROUPS) do + Kit.caption(mx + pad, cy, group.title) + cy = cy + Kit.textHeight("small") + gap + for i, spec in ipairs(group.specs) do + local col = (i - 1) % cols + local px = mx + pad + col * (colW + gap) + local py = cy + math.floor((i - 1) / cols) * (rowH + gap) + local active = ctl and ctl.spec == spec + if Kit.button(px, py, colW, rowH, spec, + { id = "bind-" .. spec, kind = active and "accent" or nil, + font = "small" }) then + Studio.setBindSpec(spec) + Studio.closeModal() + end + end + cy = cy + math.ceil(#group.specs / cols) * (rowH + gap) + gap + end + modal.contentH = cy - startY + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, + Kit.scrollExtent(contentH, viewH)) + + if ctl then + Kit.text("small", Kit.ellipsize("small", + "Bind: " .. ctl.spec .. " -> " .. TouchSkin.describeBind(ctl.spec), + mw - pad * 2 - 120 * Kit.scale), mx + pad, by + Kit.scale * 8, PAL.muted) + end +end + +local function drawImageModal(W, H) + local modal = Studio.modal + local mx, my, mw, mh, pad = modalFrame(W, H, + "Choose art for the " .. Studio.imageTargetLabel(), 0.7, 0.8) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by, bh = modalFooter(mx, my, mw, mh, pad) + if Kit.button(mx + pad, by, 150 * Kit.scale, bh, "Import a file...", + { id = "modal-import", kind = "accent", + enabled = FilePicker.available() }) then + local target = Studio.imageTarget + Studio.closeModal() + Studio.importImageFile(target) + end + + local viewH = by - top - pad + local gap = 8 * Kit.scale + local tile = math.max(72 * Kit.scale, 96 * Kit.scale) + local cols = math.max(2, math.floor((mw - pad * 2) / (tile + gap))) + local tileW = (mw - pad * 2 - gap * (cols - 1)) / cols + local tileH = tileW * 0.75 + Kit.textHeight("micro") + 6 * Kit.scale + + local list = { false } + for _, rel in ipairs(Studio.images or {}) do list[#list + 1] = rel end + local rows = math.ceil(#list / cols) + local contentH = rows * (tileH + gap) + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local maxScroll = Kit.scrollExtent(contentH, viewH) + local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) + local current = Studio.currentImagePath() + + for i, rel in ipairs(list) do + local col = (i - 1) % cols + local row = math.floor((i - 1) / cols) + local px = mx + pad + col * (tileW + gap) + local py = baseY + row * (tileH + gap) + local selected = (rel or nil) == current + local clicked = Kit.row(px, py, tileW, tileH, selected, "imgtile-" .. i) + local art = rel and Studio.thumb(rel) or nil + local artH = tileH - Kit.textHeight("micro") - 6 * Kit.scale + if art and art.getWidth then + local iw, ih = art:getWidth(), art:getHeight() + if iw > 0 and ih > 0 then + local scale = math.min((tileW - 8 * Kit.scale) / iw, + (artH - 8 * Kit.scale) / ih) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(art, px + (tileW - iw * scale) * 0.5, + py + (artH - ih * scale) * 0.5, 0, scale, scale) + end + elseif not rel then + Kit.textCenter("small", "(none)", px, + py + artH * 0.5 - Kit.textHeight("small") * 0.5, tileW, PAL.muted) + end + Kit.textCenter("micro", Kit.ellipsize("micro", rel or "no art", tileW), + px, py + tileH - Kit.textHeight("micro") - 2 * Kit.scale, tileW, + selected and PAL.green or PAL.detail) + if clicked then Studio.chooseImage(rel or nil) end + end + if #list == 1 then + Kit.text("small", "No images in this skin yet. Import one.", mx + pad, + baseY, PAL.muted) + end + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) +end + +local function drawOpenModal(W, H) + local modal = Studio.modal + local mx, my, mw, mh, pad = modalFrame(W, H, "Open a skin", 0.62, 0.76) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by = modalFooter(mx, my, mw, mh, pad) + local viewH = by - top - pad + local rowH = math.max(Kit.tapMin(), 46 * Kit.scale) + local gap = 4 * Kit.scale + local list = Studio.available or {} + -- A visible Off choice is more intentional than making people infer that + -- an empty selection disables the overlay. It also works from the skin + -- grid on phones, where the normal pad editor is not the destination. + local offH = rowH + gap + local contentH = offH + #list * (rowH + gap) + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local maxScroll = Kit.scrollExtent(contentH, viewH) + local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) + + local offClicked = Kit.row(mx + pad, baseY, mw - pad * 2, rowH, false, + "open-off") + Kit.text("mono", "Off", mx + pad * 2, baseY + 6 * Kit.scale, PAL.red) + Kit.text("small", "Hide all on-screen controls.", mx + pad * 2, + baseY + 6 * Kit.scale + Kit.textHeight("mono"), PAL.muted) + if offClicked then Studio.disableTouchControls() end + + for i, entry in ipairs(list) do + local py = baseY + offH + (i - 1) * (rowH + gap) + local selected = Studio.skin and Studio.skin.id == entry.id + local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected, + "open-" .. entry.id) + Kit.text("mono", Kit.ellipsize("mono", entry.id, mw - pad * 4), + mx + pad * 2, py + 6 * Kit.scale, selected and PAL.green or PAL.heading) + Kit.text("small", Kit.ellipsize("small", Studio.skinSummary(entry), + mw - pad * 4), mx + pad * 2, py + 6 * Kit.scale + Kit.textHeight("mono"), + PAL.muted) + if clicked then Studio.loadEntry(entry.id) end + end + if #list == 0 then + Kit.text("small", "No skins installed yet.", mx + pad, baseY, PAL.muted) + end + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) +end + +local function drawPageModal(W, H) + local modal = Studio.modal + local skin = Studio.skin + local mx, my, mw, mh, pad = modalFrame(W, H, "Pages", 0.58, 0.72) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by, bh = modalFooter(mx, my, mw, mh, pad) + local gap = 6 * Kit.scale + -- Keep the page name easy to edit: one full-width field, then its actions + -- below it, with Close retaining its own footer row. + local fieldY = by - bh * 2 - gap * 2 + local actionY = by - bh - gap + local innerW = mw - pad * 2 + local actionW = (innerW - gap) * 0.5 + Studio.pageNameField = Kit.textfield("pagename", mx + pad, fieldY, innerW, bh, + Studio.pageNameField or "", "page name") + if Kit.button(mx + pad, actionY, actionW, bh, "Rename", + { id = "page-rename" }) then + Studio.renamePage(Studio.pageNameField) + end + if Kit.button(mx + pad + actionW + gap, actionY, actionW, + bh, "Delete", { id = "page-del", kind = "danger", + enabled = skin and #skin.pages > 1 }) then + local index = Studio.pageIndex + Studio.closeModal() + Studio.ask("Delete this page and everything on it?", + function() Studio.deletePage(index) end, "Delete") + end + + local viewH = fieldY - top - pad + local rowH = math.max(Kit.tapMin(), 44 * Kit.scale) + local pages = (skin and skin.pages) or {} + local contentH = #pages * (rowH + gap) + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local maxScroll = Kit.scrollExtent(contentH, viewH) + local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) + for i = 1, #pages do + local py = baseY + (i - 1) * (rowH + gap) + local selected = i == Studio.pageIndex + local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected, + "page-" .. i) + local name, detail = Studio.pageLabel(i) + Kit.text("mono", Kit.ellipsize("mono", i .. ". " .. name, mw - pad * 4), + mx + pad * 2, py + 5 * Kit.scale, selected and PAL.green or PAL.heading) + Kit.text("small", Kit.ellipsize("small", detail, mw - pad * 4), + mx + pad * 2, py + 5 * Kit.scale + Kit.textHeight("mono"), PAL.muted) + if clicked then + Studio.setPage(i) + Studio.pageNameField = name + end + end + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) +end + +local function drawExportModal(W, H) + local mx, my, mw, mh, pad = modalFrame(W, H, "Export this skin", 0.56, 0.6) + local cy = my + pad + Kit.textHeight("title") + pad + local rowH = math.max(Kit.tapMin(), 34 * Kit.scale) + local gap = 6 * Kit.scale + local by, bh = modalFooter(mx, my, mw, mh, pad) + for _, spec in ipairs(Studio.EXPORTS) do + if Kit.button(mx + pad, cy, mw - pad * 2, rowH, spec.label, + { id = "export-" .. spec.id, kind = "accent" }) then + Studio.exportAs(spec.id) + Studio.closeModal() + end + cy = cy + rowH + 2 * Kit.scale + Kit.text("small", Kit.ellipsize("small", spec.hint, mw - pad * 2), + mx + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + gap + end + if Studio.lastExport then + if Kit.button(mx + pad, by, 150 * Kit.scale, bh, "Show the file", + { id = "export-reveal" }) then + Studio.revealExport() + end + end + Kit.textWrapped("small", + "Exports land in the skins folder of your save directory.", + mx + pad, cy, mw - pad * 2, PAL.faint, 2) +end + +local function drawScreenModal(W, H) + local modal = Studio.modal + local mx, my, mw, mh, pad = modalFrame(W, H, "Screen & canvas", 0.62, 0.82) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by, bh = modalFooter(mx, my, mw, mh, pad) + local gap = 6 * Kit.scale + local rowH = math.max(Kit.tapMin(), 34 * Kit.scale) + local innerW = mw - pad * 2 + local half = (innerW - gap) * 0.5 + local page = Studio.page() + local locked = Studio.canvas().lockViewport + + local vpOn = page and (page.viewport or page.screenFit == "remainder") + if Kit.button(mx + pad, top, half, rowH, + vpOn and "Cutout: ON" or "Cutout: OFF", + { id = "screen-cutout", active = vpOn == true, + enabled = not locked }) then + Studio.toggleViewport() + end + if Kit.button(mx + pad + half + gap, top, half, rowH, + Studio.aspectLock and "Shape: 10:9" or "Shape: Free", + { id = "screen-shape", active = Studio.aspectLock }) then + Studio.aspectLock = not Studio.aspectLock + end + top = top + rowH + gap + if Kit.button(mx + pad, top, innerW, rowH, "Detect screen from bezel", + { id = "screen-bezel", + enabled = page ~= nil and page.imagePath ~= nil and not locked }) then + Studio.detectViewport() + end + top = top + rowH + gap + local live = Studio.canvas() + local detectLabel = ("Detect this screen (%dx%d)"):format(live.w, live.h) + if live.id ~= "this_device" then + local w, h = Studio.deviceSize() + detectLabel = ("Detect this screen (%dx%d)"):format(w, h) + end + if Kit.button(mx + pad, top, innerW, rowH, detectLabel, + { id = "screen-device", kind = "accent" }) then + Studio.detectDeviceCanvas() + end + top = top + rowH + gap * 1.5 + Kit.caption(mx + pad, top, "CANVAS") + top = top + Kit.textHeight("small") + gap + + local viewH = by - top - pad + local canvases = Studio.CANVASES + local contentH = #canvases * (rowH + gap) + local at = modalScroll(modal, mx + pad, top, innerW, viewH, contentH) + local maxScroll = Kit.scrollExtent(contentH, viewH) + local baseY = Kit.scrollBegin(mx + pad, top, innerW, viewH, at, maxScroll) + for i, spec in ipairs(canvases) do + local py = baseY + (i - 1) * (rowH + gap) + local selected = i == Studio.canvasIndex + local clicked = Kit.row(mx + pad, py, innerW, rowH, selected, + "canvas-" .. spec.id) + local label = spec.label + local detail + if spec.live then + local w, h = Studio.deviceSize() + detail = ("%dx%d · this window"):format(w, h) + else + detail = ("%dx%d"):format(spec.w, spec.h) + end + Kit.text("mono", Kit.ellipsize("mono", label, innerW - pad), + mx + pad * 2, py + 4 * Kit.scale, selected and PAL.green or PAL.heading) + Kit.text("small", Kit.ellipsize("small", detail, innerW - pad), + mx + pad * 2, py + 4 * Kit.scale + Kit.textHeight("mono"), PAL.muted) + if clicked then Studio.setCanvas(i) end + end + Kit.scrollEnd(mx + pad, top, innerW, viewH, at, maxScroll) +end + +local function drawConfirm(W, H) + local c = Studio.confirm + local mx, my, mw, mh, pad = modalFrame(W, H, "Unsaved changes", 0.42, 0.3) + Kit.textWrapped("button", c.text, mx + pad, + my + pad + Kit.textHeight("title") + pad, mw - pad * 2, PAL.text, 3) + local bh = math.max(Kit.tapMin(), 32 * Kit.scale) + local by = my + mh - pad - bh + local bw = (mw - pad * 2 - 12 * Kit.scale) / 3 + if Kit.button(mx + pad, by, bw, bh, "Save first", + { id = "confirm-save", kind = "accent" }) then + Studio.save() + if not Studio.dirty then Studio.confirmYes() end + end + if Kit.button(mx + pad + bw + 6 * Kit.scale, by, bw, bh, c.yesLabel, + { id = "confirm-yes", kind = "danger" }) then + Studio.confirmYes() + end + if Kit.button(mx + pad + (bw + 6 * Kit.scale) * 2, by, bw, bh, "Cancel", + { id = "confirm-no" }) then + Studio.confirmNo() + end +end + +function Studio.drawOverlay(W, H) + if Studio.confirm then + drawConfirm(W, H) + return true + end + local modal = Studio.modal + if not modal then return false end + if modal.kind == "bind" then drawBindModal(W, H) + elseif modal.kind == "image" then drawImageModal(W, H) + elseif modal.kind == "open" then drawOpenModal(W, H) + elseif modal.kind == "page" then drawPageModal(W, H) + elseif modal.kind == "export" then drawExportModal(W, H) + elseif modal.kind == "screen" then drawScreenModal(W, H) + else Studio.closeModal() end + return true +end + +local function drawLegacyStudio() + local winW, winH, ox, oy, W, H = Studio.safeFrame() + Kit.layout(W, H) + local mx, my = love.mouse.getPosition() + if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end + Kit.beginFrame(mx - ox, my - oy, Studio.clicked, Studio.wheel) + Studio.clicked, Studio.wheel = false, 0 + + Theme.fill(0, 0, winW, winH, PAL.bg, 1) + Studio.expireStatus() + Kit.blockClicks = Studio.modalUp() + love.graphics.push() + love.graphics.translate(ox, oy) + + local pad = 14 * Kit.scale + local mobile = Studio.isMobile() + local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) + -- A phone gets an intentional two-row toolbar. The desktop's single row + -- is excellent at a wide monitor, but it would run its canvas/test/history + -- controls beneath Close on a portrait display. + local barH = mobile and (btnH * 2 + pad * 1.5) or (btnH + pad) + + Kit.textBold("title", "Skin Studio", pad, pad * 0.6, PAL.heading) + local titleW = Kit.textWidth("title", "Skin Studio") + pad * 2 + + local toolY = mobile and (pad * 0.5 + btnH + pad * 0.25) or (pad * 0.5) + local bx = mobile and pad or titleW + local canvas = Studio.canvas() + local canvasW = mobile and math.min(200 * Kit.scale, W * 0.4) or 200 * Kit.scale + if Kit.button(bx, toolY, canvasW, btnH, + canvas.label, { id = "canvas" }) then + Studio.setCanvas(Studio.canvasIndex + 1) + end + bx = bx + canvasW + 8 * Kit.scale + local testW = mobile and math.min(110 * Kit.scale, W * 0.21) or 110 * Kit.scale + if Kit.button(bx, toolY, testW, btnH, + Studio.testing and "Test: ON" or "Test: OFF", + { id = "test", active = Studio.testing }) then + Studio.testing = not Studio.testing + TouchControls:setPreview(not Studio.testing) + TouchControls:reset() + end + bx = bx + testW + 8 * Kit.scale + local smallW = 74 * Kit.scale + if Kit.button(bx, toolY, smallW, btnH, "Undo", + { id = "undo", font = "small", enabled = Studio.canUndo() }) then + Studio.undo() + end + bx = bx + smallW + 6 * Kit.scale + if Kit.button(bx, toolY, smallW, btnH, "Redo", + { id = "redo", font = "small", enabled = Studio.canRedo() }) then + Studio.redo() + end + bx = bx + smallW + 6 * Kit.scale + if not mobile then + if Kit.button(bx, toolY, smallW + 20 * Kit.scale, btnH, + Studio.showLabels and "Labels: ON" or "Labels: OFF", + { id = "labels", font = "small", active = Studio.showLabels }) then + Studio.showLabels = not Studio.showLabels + end + bx = bx + smallW + 26 * Kit.scale + end + + if Studio.dirty then + Kit.text("small", "unsaved", bx, toolY + btnH * 0.3, PAL.yellow) + end + + local closeW = 100 * Kit.scale + local offW = 74 * Kit.scale + if Kit.button(W - pad - closeW - offW - 6 * Kit.scale, pad * 0.5, offW, btnH, + "Off", { id = "off", kind = "danger" }) then + Studio.disableTouchControls() + end + local closed = false + if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close", + { id = "close" }) then + Studio.guard("Close the studio and lose the unsaved changes?", function() + closed = true + if Studio.onClose then Studio.onClose() end + end) + end + if closed then + Kit.blockClicks = false + love.graphics.pop() + Kit.endFrame() + return + end + + local bodyY = barH + pad * 0.5 + local bodyH = H - bodyY - pad + local r, cx, cw, footY + if Studio.isMobile() then + -- On a phone the canvas stays wide and the inspector becomes the lower + -- sheet. Both remain on screen, so a tapped control can be adjusted + -- without swapping modes or hiding the preview. + cx, cw = pad, W - pad * 2 + local canvasH = math.max(180 * Kit.scale, (bodyH - 46 * Kit.scale) * 0.46) + r = drawCanvas(cx, bodyY, cw, canvasH) + local inspectorY = bodyY + canvasH + 8 * Kit.scale + local inspectorH = math.max(0, H - inspectorY - pad - 30 * Kit.scale) + drawInspector(pad, inspectorY, W - pad * 2, inspectorH) + footY = H - pad - 24 * Kit.scale + else + local panelW = math.min(360 * Kit.scale, W * 0.34) + drawInspector(pad, bodyY, panelW, bodyH) + cx = pad * 2 + panelW + cw = W - cx - pad + r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale) + footY = bodyY + bodyH - 30 * Kit.scale + end + local msg = Studio.status + if not msg and Studio.testing then + local held = {} + for btn in pairs(TouchControls.held or {}) do held[#held + 1] = btn end + table.sort(held) + msg = "TEST: click the pad. Held: " + .. (held[1] and table.concat(held, ", ") or "(none)") + end + if not msg then + msg = "Drag to move, corner handles to resize, blue box is the game screen." + end + Kit.text("small", Kit.ellipsize("small", msg, cw), cx, footY, + Studio.statusErr and PAL.red or PAL.detail) + + Kit.blockClicks = false + love.graphics.pop() + if r then r.x, r.y = r.x + ox, r.y + oy end + if Studio.lastCanvas then + Studio.lastCanvas.x = Studio.lastCanvas.x + ox + Studio.lastCanvas.y = Studio.lastCanvas.y + oy + end + if Studio.canvasWorkspace then + Studio.canvasWorkspace.x = Studio.canvasWorkspace.x + ox + Studio.canvasWorkspace.y = Studio.canvasWorkspace.y + oy + end + Studio.drawOverlay(W, H) + + Kit.endFrame() + Studio.canvasArea = r +end + +-- ------------------------------------------------------ mobile-first studio + +-- Kept as an opt-in diagnostic renderer while downstream tools migrate; the +-- active Studio path below is the library/editor flow. +Studio.drawLegacy = drawLegacyStudio + +local function studioCard(x, y, w, h, id, active) + local focused = Kit.focusable(id, x, y, w, h) + Kit.card(x, y, w, h, active and "selected" + or (focused or Kit.hover(x, y, w, h))) + return Kit.press(x, y, w, h) or Kit._activateId == id +end + +local function drawSkinArtwork(image, x, y, w, h) + Theme.fillRounded(x, y, w, h, PAL.bg, 1, Theme.cardRadius() * 0.65) + if image and image.getDimensions then + local iw, ih = image:getDimensions() + if iw > 0 and ih > 0 then + local s = math.min((w - 14 * Kit.scale) / iw, (h - 14 * Kit.scale) / ih) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image, x + (w - iw * s) * 0.5, + y + (h - ih * s) * 0.5, 0, s, s) + return + end + end + -- A neutral mock reads as a touch skin even before it has artwork. + Theme.strokeRounded(x + w * 0.19, y + h * 0.10, w * 0.62, h * 0.44, + PAL.line, Theme.A.hairline, 1, 3) + Theme.fillRounded(x + w * 0.13, y + h * 0.64, w * 0.26, h * 0.18, + PAL.steel, 0.8, 3) + Theme.fillRounded(x + w * 0.60, y + h * 0.59, w * 0.13, h * 0.24, + PAL.steel, 0.8, h * 0.12) + Theme.fillRounded(x + w * 0.77, y + h * 0.54, w * 0.13, h * 0.24, + PAL.steel, 0.8, h * 0.12) +end + +local function drawLibrary() + local f = Studio._frame + local ox, oy, W, H = f.x, f.y, f.w, f.h + local pad = 14 * Kit.scale + local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) + local titleY = oy + pad * 0.55 + Kit.textBold("title", "My Skins", ox + pad, titleY, PAL.heading) + local closeW = math.min(112 * Kit.scale, W * 0.24) + if Kit.button(ox + W - pad - closeW, oy + pad * 0.5, closeW, btnH, "Close", + { id = "library-close" }) then + if Studio.onClose then Studio.onClose() end + return + end + + local y = oy + pad * 0.5 + btnH + pad + local gap = 8 * Kit.scale + local half = (W - pad * 2 - gap) * 0.5 + if Kit.button(ox + pad, y, half, btnH * 1.12, "+ New skin", + { id = "library-new", kind = "accent" }) then + Studio.newSkin() + Studio.enterEditor() + return + end + if Kit.button(ox + pad + half + gap, y, half, btnH * 1.12, "Import skin", + { id = "library-import" }) then + Studio.importSkinFile() + return + end + y = y + btnH * 1.12 + pad + + Kit.caption(ox + pad, y, "YOUR SKINS") + y = y + Kit.textHeight("small") + gap + + local entries = Studio.available or {} + local cardGap = 10 * Kit.scale + local listX, listTop = ox + pad, y + local listW = W - pad * 2 + local listH = math.max(0, (oy + H) - listTop - pad) + local gridW = listW - Kit.scrollGutter() + local cols = math.max(2, math.min(5, + math.floor((gridW + cardGap) / (250 * Kit.scale + cardGap)))) + local cardW = (gridW - cardGap * (cols - 1)) / cols + local textH = Kit.textHeight("mono") + Kit.textHeight("small") + local artH = math.min(cardW * 0.52, math.max(52 * Kit.scale, listH * 0.34)) + local cardH = artH + textH + btnH * 2 + 34 * Kit.scale + local activeId = Studio.activeSkinId() + + local rows = math.ceil(#entries / cols) + local contentH = rows * cardH + math.max(0, rows - 1) * cardGap + local maxScroll = Kit.scrollExtent(contentH, listH) + Studio.libraryScroll = Kit.scrollWheel(Studio.libraryScroll or 0, maxScroll, + listX, listTop, listW, listH) + Studio.libraryScroll = Kit.scrollClamp(Studio.libraryScroll, maxScroll) + local focusId = (Kit._ringShown and Kit.focusId) or nil + if focusId ~= Studio.libraryFocusId then + Studio.libraryFocusId = focusId + local focusSkin = focusId and tostring(focusId):match("^skin%-%a+%-(.+)$") + if focusSkin and maxScroll > 0 then + for i = 1, #entries do + if entries[i].id == focusSkin then + local bandTop = math.floor((i - 1) / cols) * (cardH + cardGap) + if bandTop < Studio.libraryScroll then + Studio.libraryScroll = Kit.scrollClamp(bandTop, maxScroll) + elseif bandTop + cardH > Studio.libraryScroll + listH then + Studio.libraryScroll = Kit.scrollClamp(bandTop + cardH - listH, + maxScroll) + end + break + end + end + end + end + Studio.libraryScrollMax = maxScroll + Studio.libraryView = { x = listX, y = listTop, w = listW, h = listH } + Studio.libraryRects = {} + + local jumped = false + local top = Kit.scrollBegin(listX, listTop, listW, listH, + Studio.libraryScroll, maxScroll) + for slot = 1, #entries do + local index = slot - 1 + local cx = listX + (index % cols) * (cardW + cardGap) + local cy = top + math.floor(index / cols) * (cardH + cardGap) + if cy + cardH >= listTop - listH and cy <= listTop + listH * 2 then + local entry = entries[slot] + local selected = entry.id == activeId + Kit.card(cx, cy, cardW, cardH, selected and "selected" or nil) + drawSkinArtwork(Studio.libraryThumb(entry), cx + 7 * Kit.scale, + cy + 7 * Kit.scale, cardW - 14 * Kit.scale, artH) + Kit.text("mono", Kit.ellipsize("mono", entry.id, cardW - 20 * Kit.scale), + cx + 10 * Kit.scale, cy + artH + 15 * Kit.scale, + selected and PAL.green or PAL.heading) + local meta = Studio.skinSummary(entry) + Kit.text("small", Kit.ellipsize("small", meta, cardW - 20 * Kit.scale), + cx + 10 * Kit.scale, cy + artH + 15 * Kit.scale + Kit.textHeight("mono"), + PAL.muted) + local actionGap = 4 * Kit.scale + local actionW = (cardW - 20 * Kit.scale - actionGap) * 0.5 + local actionY = cy + cardH - btnH * 2 - actionGap - 8 * Kit.scale + Studio.libraryRects[entry.id] = + { x = cx + 8 * Kit.scale, y = actionY, w = actionW, h = btnH } + if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH, + selected and "Selected" or "Select", + { id = "skin-select-" .. entry.id, + kind = selected and "good" or "accent" }) then + Studio.selectEntry(entry.id) + end + if Kit.button(cx + 8 * Kit.scale + actionW + actionGap, actionY, actionW, btnH, + "Edit", { id = "skin-edit-" .. entry.id, kind = "accent" }) then + Studio.loadEntry(entry.id) + Studio.enterEditor() + jumped = true + break + end + actionY = actionY + btnH + actionGap + if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH, + "Export", { id = "skin-export-" .. entry.id }) then + Studio.exportEntry(entry.id) + end + if Kit.button(cx + 8 * Kit.scale + actionW + actionGap, actionY, actionW, btnH, + "Delete", { id = "skin-delete-" .. entry.id, + kind = "danger", enabled = entry.source == "user" }) then + Studio.deleteEntry(entry.id) + end + end + end + Kit.scrollEnd(listX, listTop, listW, listH, Studio.libraryScroll, maxScroll) + if jumped then return end + + if #entries == 0 then + Kit.text("small", "Create a blank skin or import one to get started.", + listX, listTop + gap, PAL.muted) + end +end + +local function drawEditor() + local f = Studio._frame + local ox, oy, W, H = f.x, f.y, f.w, f.h + local pad = 14 * Kit.scale + local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) + local gap = 7 * Kit.scale + local backW = math.min(120 * Kit.scale, W * 0.23) + if Kit.button(ox + pad, oy + pad * 0.5, backW, btnH, "My Skins", + { id = "editor-back" }) then + Studio.backToLibrary() + end + local closeW = math.min(90 * Kit.scale, W * 0.17) + local saveW = math.min(90 * Kit.scale, W * 0.17) + local testW = math.min(100 * Kit.scale, W * 0.18) + local right = ox + W - pad + if Kit.button(right - closeW, oy + pad * 0.5, closeW, btnH, "Close", + { id = "editor-close" }) then + Studio.guard("Close the studio and lose the unsaved changes?", function() + if Studio.onClose then Studio.onClose() end + end) + end + right = right - closeW - gap + if Kit.button(right - saveW, oy + pad * 0.5, saveW, btnH, "Save", + { id = "editor-save", kind = "accent" }) then Studio.save() end + right = right - saveW - gap + if Kit.button(right - testW, oy + pad * 0.5, testW, btnH, + Studio.testing and "Test: ON" or "Test", + { id = "editor-test", active = Studio.testing }) then + Studio.testing = not Studio.testing + TouchControls:setPreview(not Studio.testing) + TouchControls:reset() + end + local titleX = ox + pad + backW + gap + local titleW = math.max(0, right - testW - gap - titleX) + Kit.textBold("button", Kit.ellipsize("button", + (Studio.skin and Studio.skin.name) or "Untitled skin", titleW), + titleX, oy + pad * 0.5 + (btnH - Kit.textHeight("button")) * 0.5, PAL.heading) + + local zH = math.max(Kit.tapMin(), 28 * Kit.scale) + local trayH = btnH * 2 + zH + gap * 4 + Kit.textHeight("small") + gap + local bodyY = oy + pad * 0.5 + btnH + pad + local trayY = oy + H - pad - trayH + local canvasH = math.max(120 * Kit.scale, trayY - bodyY - pad) + drawCanvas(ox + pad, bodyY, W - pad * 2, canvasH) + + Kit.card(ox + pad, trayY, W - pad * 2, trayH) + local innerX, innerW = ox + pad + gap, W - pad * 2 - gap * 2 + local actionW = (innerW - gap * 3) / 4 + local ctl = Studio.selectedControl() + if Kit.button(innerX, trayY + gap, actionW, btnH, "+ Control", + { id = "tray-add", kind = "accent" }) then + Studio.addControl() + Studio.openBindPicker() + end + if Kit.button(innerX + (actionW + gap), trayY + gap, actionW, btnH, + ctl and ("Bind: " .. ctl.spec) or "Bind", + { id = "tray-bind", enabled = ctl ~= nil }) then + Studio.openBindPicker() + end + if Kit.button(innerX + (actionW + gap) * 2, trayY + gap, actionW, btnH, + "Button art", { id = "tray-art", enabled = ctl ~= nil }) then + Studio.openImagePicker("idle") + end + if Kit.button(innerX + (actionW + gap) * 3, trayY + gap, actionW, btnH, + "Bezel art", { id = "tray-bezel" }) then + Studio.openImagePicker("bezel") + end + + local row2 = trayY + gap * 2 + btnH + if Kit.button(innerX, row2, actionW, btnH, "Pages", + { id = "tray-pages" }) then Studio.openPageMenu() end + if Kit.button(innerX + (actionW + gap), row2, actionW, btnH, + "Screen", { id = "tray-screen" }) then Studio.openScreenMenu() end + if Kit.button(innerX + (actionW + gap) * 2, row2, actionW, btnH, + Studio.aspectLock and "Shape: 10:9" or "Shape: Free", + { id = "tray-shape", active = Studio.aspectLock }) then + Studio.aspectLock = not Studio.aspectLock + end + if Kit.button(innerX + (actionW + gap) * 3, row2, actionW, btnH, + "Delete", { id = "tray-delete", kind = "danger", enabled = ctl ~= nil }) then + Studio.deleteControl() + end + + local row3 = row2 + btnH + gap + local zoomAt = Studio.zoomIndex() + if Kit.button(innerX, row3, actionW, zH, "Detect screen", + { id = "tray-detect" }) then + Studio.detectDeviceCanvas() + end + if Kit.button(innerX + (actionW + gap), row3, actionW, zH, "Zoom -", + { id = "zoom-out", enabled = zoomAt < #Studio.ZOOM_LEVELS }) then + Studio.zoomOut() + end + local pct = math.floor((Studio.viewZoom or 1) * 100 + 0.5) .. "%" + if Kit.button(innerX + (actionW + gap) * 2, row3, actionW, zH, "Fit " .. pct, + { id = "zoom-fit", active = zoomAt == 1 }) then + Studio.zoomFit() + end + if Kit.button(innerX + (actionW + gap) * 3, row3, actionW, zH, "Zoom +", + { id = "zoom-in", enabled = zoomAt > 1 }) then + Studio.zoomIn() + end + + local hint = ctl and ("Selected: " .. TouchSkin.describeBind(ctl.spec) + .. " — drag to move; use blue handles to resize.") + or "Tap a control to select it. Drag to move; use blue handles to resize." + Kit.text("small", Kit.ellipsize("small", Studio.status or hint, innerW), + innerX, trayY + trayH - Kit.textHeight("small") - gap, + Studio.statusErr and PAL.red or PAL.muted) +end + +function Studio.draw() + local W, H = Studio.safeFrame() + local f = Studio._frame + Kit.layout(f.w, f.h) + local mx, my = love.mouse.getPosition() + local px, py, padActive = PadCursor.pointer() + if padActive then + mx, my = px, py + elseif Studio.pointerX ~= nil then + mx, my = Studio.pointerX, Studio.pointerY + end + Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel) + Studio.clicked, Studio.wheel = false, 0 + Theme.fill(0, 0, W, H, PAL.bg, 1) + Studio.expireStatus() + Kit.blockClicks = Studio.modalUp() + + if Studio.mode == "library" then drawLibrary() + else drawEditor() end + + Kit.blockClicks = false + Studio.drawOverlay(f.w, f.h) + Kit.endFrame() + PadCursor.draw() +end + +-- ---------------------------------------------------------------- input + +-- Test uses the mock device drawn inside the workspace. The draw pass has +-- already relinquished that surface, so input must scope the same rectangle +-- explicitly and restore its caller's surface afterwards. +local function testPad(method,x,y) + local previous=TouchSkin.surfaceRect + local r=Studio.lastCanvas + if r then TouchSkin.setSurface(r.x,r.y,r.w,r.h) end + local ok,result=pcall(TouchControls[method],TouchControls,'studio',x,y) + if previous then + TouchSkin.setSurface(previous.x,previous.y,previous.w,previous.h) + else TouchSkin.setSurface(nil) end + if not ok then error(result,0) end + return result +end + +function Studio.update(dt) + PadCursor.update(dt or 0) + local x, y, active = PadCursor.pointer() + if active and Studio.pointerDown then Studio.mousemoved(x, y) end + local wheel = PadCursor.takeWheel() + if wheel ~= 0 then Studio.wheelmoved(0, wheel) end + if not Studio.pendingPlay then return end + Studio.pendingPlay = false + local onPlay, version, canvas = Studio.onPlay, Studio.version, Studio.canvas() + if onPlay then onPlay(version, canvas) end +end + +function Studio.mousepressed(x, y, button, fromPad) + if button ~= 1 then return end + if not fromPad then PadCursor.yieldToPointer() end + Studio.pointerX, Studio.pointerY = x, y + Studio.pointerDown = true + if Studio.modalUp() then Studio.clicked = true return end + if Studio.mode == "library" then + if fromPad then Studio.clicked = true return end + Studio.libraryPress = { x = x, y = y, at = Studio.libraryScroll or 0 } + return + end + Studio.clicked = true + local r = Studio.lastCanvas + if not r then return end + if Studio.testing then + if x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h then + testPad('touchpressed',x,y) + end + return + end + local slop = HANDLE * 2 * Kit.scale + local work = Studio.canvasWorkspace + local insideDevice = x >= r.x - slop and x <= r.x + r.w + slop + and y >= r.y - slop and y <= r.y + r.h + slop + local insideWork = work and x >= work.x and x <= work.x + work.w + and y >= work.y and y <= work.y + work.h + if not insideDevice and not insideWork then + return + end + Studio.beginCanvasDrag(x, y, r) +end + +function Studio.mousemoved(x, y) + Studio.pointerX, Studio.pointerY = x, y + if Studio.modalUp() then return end + if Studio.mode == "library" then + local p = Studio.libraryPress + if not p then return end + local ddx, ddy = x - p.x, y - p.y + if ddx * ddx + ddy * ddy > TAP_SLOP2 then p.dragged = true end + if p.dragged then + Studio.libraryScroll = Kit.scrollClamp(p.at - (y - p.y), + Studio.libraryScrollMax or 0) + end + return + end + if Studio.testing then + testPad('touchmoved',x,y) + return + end + if Studio.drag and (Studio.pointerDown or love.mouse.isDown(1)) and Studio.lastCanvas then + Studio.updateDrag(x, y, Studio.lastCanvas) + end +end + +function Studio.mousereleased(x, y, button) + if button ~= 1 then return end + Studio.pointerX, Studio.pointerY = x, y + Studio.pointerDown = false + local press = Studio.libraryPress + Studio.libraryPress = nil + if press and not press.dragged then Studio.clicked = true end + if Studio.testing then + testPad('touchreleased',x,y) + return + end + Studio.drag = nil + Studio.guides = nil +end + +function Studio.touchpressed(id, x, y) + if Studio.touchId and Studio.touchId ~= id then return end + Studio.touchId = id + return Studio.mousepressed(x, y, 1) +end + +local function closeFromPad() + if Studio.confirm then + Studio.confirmNo() + elseif Studio.modal then + Studio.closeModal() + elseif Studio.mode == "editor" then + Studio.backToLibrary() + elseif Studio.onClose then + Studio.onClose() + end +end + +local function handlePadAction(action) + if action == "a" then + local x, y = PadCursor.pointer() + Studio.mousepressed(x, y, 1, true) + elseif action == "b" then + closeFromPad() + end +end + +function Studio.gamepadpressed(joystick, button) + handlePadAction(PadCursor.gamepadpressed(joystick, button)) +end + +function Studio.gamepadreleased(joystick, button) + PadCursor.gamepadreleased(joystick, button) + if GamepadMap.mapGamepadButton(button) == "a" then + local x, y = PadCursor.pointer() + Studio.mousereleased(x, y, 1) + end +end + +function Studio.gamepadaxis(joystick, axis, value) + PadCursor.gamepadaxis(joystick, axis, value) +end + +function Studio.joystickpressed(joystick, button) + handlePadAction(PadCursor.joystickpressed(joystick, button)) +end + +function Studio.joystickreleased(joystick, button) + PadCursor.joystickreleased(joystick, button) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + local padButton = GamepadMap.mapRawToGamepadButton(button) + if padButton and GamepadMap.mapGamepadButton(padButton) == "a" then + local x, y = PadCursor.pointer() + Studio.mousereleased(x, y, 1) + end +end + +function Studio.joystickaxis(joystick, axis, value) + PadCursor.joystickaxis(joystick, axis, value) +end + +function Studio.joystickhat(joystick, hat, direction) + PadCursor.joystickhat(joystick, hat, direction) +end + +function Studio.touchmoved(id, x, y) + if Studio.touchId ~= id then return end + return Studio.mousemoved(x, y) +end + +function Studio.touchreleased(id, x, y) + if Studio.touchId ~= id then return end + Studio.touchId = nil + return Studio.mousereleased(x, y, 1) +end + +function Studio.wheelmoved(_, dy) + if Studio.mode == "editor" and not Studio.modalUp() and dy and dy ~= 0 then + local work = Studio.canvasWorkspace + local mx, my, active = PadCursor.pointer() + if not active then mx, my = Studio.pointerX, Studio.pointerY end + if (not mx or not my) and love and love.mouse and love.mouse.getPosition then + mx, my = love.mouse.getPosition() + end + if work and mx and my + and mx >= work.x and mx <= work.x + work.w + and my >= work.y and my <= work.y + work.h then + if dy > 0 then Studio.zoomIn() else Studio.zoomOut() end + return + end + end + Studio.wheel = dy +end + +function Studio.focus() + PadCursor.reset() + Studio.drag = nil + Studio.clicked = false + Studio.libraryPress = nil + Studio.pointerDown, Studio.touchId = false, nil + if Studio.testing then TouchControls:reset() end +end + +function Studio.visible() + Studio.focus() +end + +function Studio.joystickremoved() + Studio.focus() + PadCursor.reset() +end + +function Studio.textinput(text) + Kit.textinput(text) +end + +local function heldCtrl() + if not (love and love.keyboard and love.keyboard.isDown) then return false end + local ok, down = pcall(love.keyboard.isDown, "lctrl", "rctrl", "lgui", "rgui") + return ok and down == true +end + +local function heldShift() + if not (love and love.keyboard and love.keyboard.isDown) then return false end + local ok, down = pcall(love.keyboard.isDown, "lshift", "rshift") + return ok and down == true +end + +Studio.NUDGES = { + up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 }, +} + +function Studio.keypressed(key) + if Kit.focus then + Kit.keypressed(key) + return + end + if Studio.confirm then + if key == "escape" then Studio.confirmNo() + elseif key == "return" or key == "kpenter" then Studio.confirmYes() + else Kit.keypressed(key) end + return + end + if Studio.modal then + if key == "escape" then Studio.closeModal() else Kit.keypressed(key) end + return + end + local nudge = Studio.NUDGES[key] + if nudge then + Studio.nudge(nudge[1], nudge[2], heldShift()) + return + end + if (key == "y" and heldCtrl()) + or (key == "z" and heldShift() and heldCtrl()) then + Studio.redo() + elseif key == "z" and heldCtrl() then + Studio.undo() + elseif key == "u" then + if heldShift() then Studio.redo() else Studio.undo() end + elseif key == "l" then + Studio.showLabels = not Studio.showLabels + elseif key == "escape" then + Studio.guard("Close the studio and lose the unsaved changes?", function() + if Studio.onClose then Studio.onClose() end + end) + elseif key == "delete" or key == "backspace" then + Studio.deleteControl() + elseif key == "n" then + Studio.addControl() + elseif key == "d" then + Studio.duplicateControl() + elseif key == "t" then + Studio.testing = not Studio.testing + TouchControls:setPreview(not Studio.testing) + TouchControls:reset() + elseif key == "-" or key == "kp-" then + Studio.zoomOut() + elseif key == "=" or key == "+" or key == "kp+" then + Studio.zoomIn() + elseif key == "0" or key == "kp0" then + Studio.zoomFit() + elseif key == "s" then + Studio.save() + elseif key == "tab" then + local page = Studio.page() + if page and #page.controls > 0 then + Studio.selected = ((Studio.selected or 0) % #page.controls) + 1 + end + else + Kit.keypressed(key) + end +end + +function Studio.available_desktop() + -- Kept as the compatibility name for launcher integrations. The Studio + -- now has a touch layout and is available on every supported platform. + return true +end + +return Studio diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua index 697ed23ca..d1c440476 100644 --- a/src/ui/TouchControlsEditor.lua +++ b/src/ui/TouchControlsEditor.lua @@ -14,6 +14,9 @@ local SaveData = require("src.core.SaveData") local TouchControls = require("src.core.TouchControls") +-- Shared Gen1 cursor mechanics; GPLv3/Section7 notice in NOTICE.md. +local PadCursor = require("src.ui.PadCursor") +local GamepadMap = require("src.core.GamepadMap") local Editor = {} @@ -44,6 +47,7 @@ function Editor.load(opts) opts = opts or {} Editor.onClose = opts.onClose Editor.drag = nil + PadCursor.reset() Editor.rects = {} Editor.fonts = { title = love.graphics.newFont(28), @@ -63,6 +67,7 @@ function Editor.unload() TouchControls:reset() Editor.drag = nil Editor.onClose = nil + PadCursor.reset() end local function persist() @@ -95,13 +100,16 @@ local function toggleEnabled() if not Editor.enabled then TouchControls:reset() end end -function Editor.update(_dt) +function Editor.update(dt) + PadCursor.update(dt or 0) -- drag follows the live pointer when love.touch / mouse is available; -- touchmoved / mousemoved also update, so this is a belt-and-suspenders -- path for Android where move events can be thin if not Editor.drag then return end local x, y - if love.touch and love.touch.getPosition and Editor.drag.touchId then + if Editor.drag.touchId == 'pad' then + x, y = PadCursor.pointer() + elseif love.touch and love.touch.getPosition and Editor.drag.touchId then local ok, tx, ty = pcall(love.touch.getPosition, Editor.drag.touchId) if ok and tx then x, y = tx, ty end end @@ -287,6 +295,7 @@ function Editor.draw() love.graphics.circle("line", zone.cx, zone.cy, zone.w * 0.62) end end + PadCursor.draw() end local function beginDrag(id, x, y) @@ -301,6 +310,9 @@ local function beginDrag(id, x, y) TouchControls:nudgeScale(TouchControls.SCALE_STEP); return end + -- A custom skin's controls have arbitrary shapes/specs rather than native + -- pad layout keys. Its dedicated studio owns those edits. + if require('src.core.TouchSkin').active then return end local name = TouchControls:hitTest(x, y) if not name then return end local zone = TouchControls:layout()[name] @@ -328,6 +340,7 @@ end function Editor.mousepressed(x, y, button) if button ~= 1 then return end + PadCursor.yieldToPointer() beginDrag("mouse", x, y) end @@ -341,6 +354,7 @@ function Editor.mousereleased(x, y, button) end function Editor.touchpressed(id, x, y) + PadCursor.yieldToPointer() beginDrag(id, x, y) end @@ -352,6 +366,65 @@ function Editor.touchreleased(id, x, y) endDrag(id) end +local function handlePadAction(action) + if not action then return end + if action == "a" then + local mx, my = PadCursor.pointer() + beginDrag("pad", mx, my) + elseif action == "b" then + close() + elseif action == "tab_prev" then + TouchControls:nudgeScale(-TouchControls.SCALE_STEP) + elseif action == "tab_next" then + TouchControls:nudgeScale(TouchControls.SCALE_STEP) + end +end + +function Editor.gamepadpressed(joystick, button) + handlePadAction(PadCursor.gamepadpressed(joystick, button)) +end + +function Editor.gamepadreleased(joystick, button) + PadCursor.gamepadreleased(joystick, button) + -- A release ends a pad drag (hold A + stick to reposition a control). + local action = GamepadMap.mapGamepadButton(button) + if action == "a" then endDrag("pad") end +end + +function Editor.gamepadaxis(joystick, axis, value) + PadCursor.gamepadaxis(joystick, axis, value) +end + +function Editor.joystickpressed(joystick, button) + handlePadAction(PadCursor.joystickpressed(joystick, button)) +end + +function Editor.joystickreleased(joystick, button) + PadCursor.joystickreleased(joystick, button) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + local padButton = GamepadMap.mapRawToGamepadButton(button) + if padButton then + local action = GamepadMap.mapGamepadButton(padButton) + if action == "a" then endDrag("pad") end + end +end + +function Editor.joystickaxis(joystick, axis, value) + PadCursor.joystickaxis(joystick, axis, value) +end + +function Editor.joystickhat(joystick, hat, direction) + PadCursor.joystickhat(joystick, hat, direction) +end + +function Editor.focus() + Editor.drag=nil + PadCursor.reset() + TouchControls:reset() +end +Editor.visible=Editor.focus +Editor.joystickremoved=Editor.focus + function Editor.keypressed(key) if key == "escape" or key == "return" or key == "space" then close() diff --git a/src/ui/kit/Button.lua b/src/ui/kit/Button.lua new file mode 100644 index 000000000..3a817e7fd --- /dev/null +++ b/src/ui/kit/Button.lua @@ -0,0 +1,350 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Shared button faces for launcher and save-editor input adapters. +-- The owner supplies fonts, pointer state and text drawing; this module only paints. +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL +local Icons = require("src.ui.kit.Icons") +local Button = {} +local KINDS = { + primary = { fill = PAL.green, ink = PAL.inverse }, + good = { fill = PAL.green, ink = PAL.inverse }, + accent = { fill = PAL.buttonBlue, ink = PAL.heading }, + warn = { fill = PAL.yellow, ink = PAL.inverse }, + danger = { fill = PAL.surface, ink = PAL.red, stroke = PAL.red }, + ghost = { fill = PAL.surface, ink = PAL.text, stroke = PAL.line }, + disabled = { fill = PAL.surface, ink = PAL.muted, stroke = PAL.line }, +} +Button.KINDS = KINDS + +-- Complete labels wrap between words. The input adapter sizes the control +-- from these same metrics; the painter never substitutes an ellipsis. +function Button.labelLayout(Kit, w, h, label, opts) + local name = opts.font or "button" + local lineH = Kit.textHeight(name) + local pad = Theme.BUTTON.labelInset * Kit.scale / 2 + local trailing = opts.trailingIcon and h * 0.65 or 0 + local available = w - 2 * pad - trailing + local function wrap(width) + local lines, line, widest = {}, "", 0 + for word in tostring(label):gmatch("%S+") do + local nextLine = line == "" and word or (line .. " " .. word) + if line ~= "" and Kit.textWidth(name, nextLine) > width then + lines[#lines + 1] = line + widest = math.max(widest, Kit.textWidth(name, line)) + line = word + else + line = nextLine + end + end + if line ~= "" then + lines[#lines + 1] = line + widest = math.max(widest, Kit.textWidth(name, line)) + end + return lines, widest + end + local lines, widest = wrap(available) + local mode, size, gap + if opts.icon then + size = math.floor(math.min(w, h) * 0.42) + gap = math.floor(7 * Kit.scale) + local sideW = available - size - gap + local sideLines, sideWidth = wrap(sideW) + if not opts.iconStack and sideWidth <= sideW and #sideLines * lineH <= h - 4 * Kit.scale then + lines, widest, available, mode = sideLines, sideWidth, sideW, "side" + elseif size + math.max(2, 3 * Kit.scale) + #lines * lineH <= h - 4 * Kit.scale then + mode, gap = "stack", math.max(2, 3 * Kit.scale) + end + end + return { + font = name, + lines = lines, + width = widest, + available = available, + pad = pad, + trailing = trailing, + lineH = lineH, + height = #lines * lineH + (mode == "stack" and size + gap or 0), + mode = mode, + iconSize = size, + gap = gap, + } +end + +function Button.draw(Kit, x, y, w, h, label, opts, hot, focused) + local G = love and love.graphics + local enabled = opts.enabled ~= false + local face = opts.face or "fill" + local B = Theme.BUTTON + local radius = opts.radius or B.radius + local segments = opts.segments + local active = opts.active or opts.on + local invert = false + local fill, ink, stroke, strokeA, strokeWidth, doEmboss, doRing, glowA + if face == "bare" then + ink = opts.ink or PAL.heading + doRing = focused + elseif face == "selection" then + -- A selected control keeps its geometry, dark face and label layout. + -- Selection is an inset accent outline, never a filled keycap. + fill = PAL.surface + ink = PAL.text + stroke = (active or focused) and PAL.blue or PAL.line + strokeA = (active or focused) and Theme.A.focus or (hot and Theme.A.hover or Theme.A.hairline) + strokeWidth = (active or focused) and 2 or 1 + doRing = focused or hot + elseif face == "invert" then + invert = false + fill = (hot or focused) and PAL.raised or (opts.fill or PAL.surface) + ink = opts.ink or PAL.heading + stroke = opts.stroke or PAL.line + strokeA = (hot or focused) and Theme.A.focus or Theme.A.hairline + doRing = focused or hot + elseif face == "tab" then + invert = active and true or false + local tint = opts.color or opts.fill or PAL.ink + fill = invert and tint or PAL.surface + ink = invert and PAL.inverse or (opts.color or PAL.text) + if not invert then + stroke = tint + strokeA = (focused or hot) and Theme.A.focus + or (opts.color and Theme.A.hover or Theme.A.hairline) + end + doRing = focused or hot + elseif face == "chip" then + local c = opts.color or PAL.line + invert = active and true or false + if active then + fill = c + ink = PAL.inverse + doEmboss = true + else + fill = PAL.bg + ink = c + stroke = c + strokeA = (focused or hot) and Theme.A.focus or Theme.A.hover + end + doRing = focused or hot + else + local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"] + fill = (enabled and opts.fill) or kind.fill + ink = (enabled and opts.ink) or kind.ink + doEmboss = (opts.kind == "primary" or opts.kind == "good" or opts.kind == "accent") + stroke = kind.stroke + strokeA = stroke and Theme.A.hover or nil + doRing = hot or focused + if opts.glow and enabled and not doRing then + glowA = B.glowBase + B.glowAmp * (0.5 + 0.5 * math.sin(Kit.time * B.glowHz)) + end + end + if opts.emboss ~= nil then + doEmboss = opts.emboss + end + if opts.ring ~= nil then + doRing = opts.ring + end + if G then + if face ~= "bare" then + Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius, segments) + end + if doEmboss then + local es = enabled and ((hot or focused) and B.embossHot or B.embossRest) or B.embossDisabled + Theme.emboss(x, y, w, h, es) + end + if strokeA then + Theme.strokeRounded(x, y, w, h, stroke, strokeA, strokeWidth or 1, radius, segments) + end + if doRing then + Theme.fillRounded( + x, + y, + w, + h, + PAL.ink, + Kit.mouseDown and hot and 0.04 or 0.09, + radius, + segments + ) + if face ~= "selection" then + Theme.strokeRounded( + x, + y, + w, + h, + PAL.ink, + focused and 1 or 0.7, + focused and 2 or 1, + radius, + segments + ) + end + elseif glowA then + Theme.strokeRounded( + x - B.ringPad, + y - B.ringPad, + w + 2 * B.ringPad, + h + 2 * B.ringPad, + PAL.lineStrong, + glowA, + B.ringWidth, + radius + B.ringPad, + segments + ) + end + local fname = opts.font or ((face == "chip") and "micro" or "button") + local ty = y + (h - Kit.textHeight(fname)) / 2 + local image = opts.image + local drawFn = opts.drawFn + local letter = opts.letter + local hasLabel = label and label ~= "" + local bold = opts.bold + if bold == nil then + bold = face ~= "tab" and face ~= "selection" + end + if opts.fullLabel and hasLabel then + local layout = opts.labelLayout or Button.labelLayout(Kit, w, h, label, opts) + local lx, ly = x + layout.pad, y + (h - #layout.lines * layout.lineH) / 2 + if layout.mode == "side" then + local groupW = layout.iconSize + layout.gap + layout.width + local ix = opts.align == "left" and lx or x + (w - layout.trailing - groupW) / 2 + Icons.draw( + opts.icon, + ix, + y + (h - layout.iconSize) / 2, + layout.iconSize, + opts.iconInk or ink, + enabled and 1 or B.disabledA + ) + lx = ix + layout.iconSize + layout.gap + elseif layout.mode == "stack" then + local top = y + (h - layout.height) / 2 + Icons.draw( + opts.icon, + x + (w - layout.trailing - layout.iconSize) / 2, + top, + layout.iconSize, + opts.iconInk or ink, + enabled and 1 or B.disabledA + ) + ly = top + layout.iconSize + layout.gap + end + for i, line in ipairs(layout.lines) do + local lineY = ly + (i - 1) * layout.lineH + if layout.mode == "side" or opts.align == "left" then + if bold then + Kit.textBold(layout.font, line, lx, lineY, ink) + else + Kit.text(layout.font, line, lx, lineY, ink) + end + elseif bold then + Kit.textCenterBold(layout.font, line, lx, lineY, layout.available, ink) + else + Kit.textCenter(layout.font, line, lx, lineY, layout.available, ink) + end + end + elseif opts.icon and opts.iconStack then + local size = math.floor(math.min(w * 0.5, h * 0.4)) + local labelH = Kit.textHeight(fname) + local gap = math.max(2, math.floor(3 * Kit.scale)) + local top = y + (h - size - gap - labelH) / 2 + Icons.draw(opts.icon, x + (w - size) / 2, top, size, ink, enabled and 1 or B.disabledA) + local shown = Kit.ellipsize(fname, label or "", w - B.labelInset * Kit.scale) + Kit.textCenter(fname, shown, x, top + size + gap, w, ink) + elseif opts.icon then + local box = math.min(w, h) + local size = math.floor(box * (hasLabel and 0.42 or 0.5) * (opts.iconScale or 1)) + local gap = math.floor(7 * Kit.scale) + local shown = hasLabel + and Kit.ellipsize( + fname, + label, + w - size - gap - 2 * B.labelPad * Kit.scale - (opts.trailingIcon and h * 0.65 or 0) + ) + or "" + local groupW = size + (hasLabel and gap + Kit.textWidth(fname, shown) or 0) + local ix = opts.align == "left" and x + B.labelPad * Kit.scale or x + (w - groupW) / 2 + Icons.draw(opts.icon, ix, y + (h - size) / 2, size, opts.iconInk or ink, enabled and 1 or B.disabledA) + if hasLabel then + if bold then + Kit.textBold(fname, shown, ix + size + gap, ty, ink) + else + Kit.text(fname, shown, ix + size + gap, ty, ink) + end + end + elseif image then + local box = h + local boxX, boxY = x, y + if not hasLabel then + box = math.min(w, h) + boxX = x + (w - box) / 2 + boxY = y + (h - box) / 2 + end + local iw, ih = image:getDimensions() + local pad = math.floor(box * (opts.iconPad or B.iconPad)) + local s = math.min((box - 2 * pad) / iw, (box - 2 * pad) / ih) + Theme.col(ink, enabled and 1 or B.disabledA) + G.draw( + image, + Theme.snap(boxX + (box - iw * s) / 2), + Theme.snap(boxY + (box - ih * s) / 2), + 0, + s, + s + ) + if hasLabel then + local lx = x + h + B.letterGap * Kit.scale + if bold then + Kit.textBold(fname, label, lx, ty, ink) + else + Kit.text(fname, label, lx, ty, ink) + end + end + elseif drawFn then + drawFn(x, y, w, h, invert) + elseif letter then + if opts.letterBold then + Kit.textCenterBold(fname, letter, x, ty, h, ink) + else + Kit.textCenter(fname, letter, x, ty, h, ink) + end + if hasLabel then + local lx = x + h + B.letterGap * Kit.scale + if bold then + Kit.textBold(fname, label, lx, ty, ink) + else + Kit.text(fname, label, lx, ty, ink) + end + end + elseif hasLabel then + local shown = Kit.ellipsize( + fname, + label, + w - B.labelInset * Kit.scale - (opts.trailingIcon and h * 0.65 or 0) + ) + if opts.align == "left" then + local lx = x + B.labelPad * Kit.scale + if bold then + Kit.textBold(fname, shown, lx, ty, ink) + else + Kit.text(fname, shown, lx, ty, ink) + end + elseif bold then + Kit.textCenterBold(fname, shown, x, ty, w, ink) + else + Kit.textCenter(fname, shown, x, ty, w, ink) + end + end + end + if opts.trailingIcon and G then + local size = math.floor(h * 0.4) + Icons.draw( + opts.trailingIcon, + x + w - size - B.labelPad * Kit.scale, + y + (h - size) / 2, + size, + ink, + enabled and 1 or B.disabledA + ) + end +end +return Button diff --git a/src/ui/kit/FileBrowser.lua b/src/ui/kit/FileBrowser.lua new file mode 100644 index 000000000..56550c166 --- /dev/null +++ b/src/ui/kit/FileBrowser.lua @@ -0,0 +1,391 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Immediate-mode in-launcher File Browser for handhelds and gamepads. +-- Allows navigating directories to pick ROM files (.gb/.gbc), import save files (.sav), +-- and select mod/skin archives (.zip) without requiring host desktop GUI pickers (zenity/kdialog). + +local Theme = require("src.ui.kit.Theme") +local HostShell = require("src.core.HostShell") + +-- POSIX shell quoting for the ls/test commands below. HostShell.quote is the +-- shared helper, but the launcher may run where HostShell is a minimal shim +-- (the NX gate), so fall back to the same escaping locally. +local function shQuote(path) + if HostShell and type(HostShell.quote) == "function" then + return HostShell.quote(path) + end + return "'" .. tostring(path):gsub("'", "'\\''") .. "'" +end +local PAL = Theme.PAL +local Kit = nil +local function getKit() + if not Kit then Kit = require("src.ui.kit.Kit") end + return Kit +end + +local FileBrowser = { + active = false, + title = "Select File", + mode = "rom", -- "rom", "save", "mod", "cart", "all" + currentDir = "/", + entries = {}, + selectedIdx = 1, + page = 1, + perPage = 8, + onSelect = nil, + onCancel = nil, + shortcutIdx = 1, +} + +local SHORTCUTS = { + { label = "SD Card", path = "/mnt/SDCARD" }, + { label = "GB ROMs", path = "/roms/gb" }, + { label = "GBC ROMs", path = "/roms/gbc" }, + { label = "Ports", path = "/roms/ports" }, + { label = "Game Dir", path = "." }, + { label = "Storage", path = "/storage" }, + { label = "Userdata", path = "/userdata/roms" }, + { label = "Root /", path = "/" }, +} + +local function findSdCardRoot() + local candidates = { + "/mnt/SDCARD", + "/mnt/sdcard", + "/mnt/mmc", + "/sdcard", + "/roms", + "/storage/roms", + "/userdata/roms", + "/storage", + "/userdata", + } + for _, path in ipairs(candidates) do + local ok, h = pcall(io.popen, string.format("test -d %s && echo \"yes\"", shQuote(path))) + if ok and h then + local res = h:read("*a") + h:close() + if res and res:match("yes") then + return path + end + end + end + return "/" +end + +local function normalizePath(p) + if not p or p == "" then return "/" end + p = p:gsub("\\", "/") + if p:sub(1, 1) ~= "/" and p:sub(1, 2) ~= "./" and p:sub(1, 3) ~= "../" then + p = "/" .. p + end + p = p:gsub("/+", "/") + return p +end + +local function parentDir(p) + p = normalizePath(p) + if p == "/" or p == "." then return "/" end + local parent = p:match("^(.*)/[^/]+/?$") + if not parent or parent == "" then return "/" end + return parent +end + +local function isMatchingFilter(name, isDir, mode) + if isDir then return true end + local ext = name:match("%.([^.]+)$") + if not ext then return (mode == "all") end + ext = ext:lower() + if mode == "rom" then + return ext == "gb" or ext == "gbc" or ext == "gba" or ext == "agb" + or ext == "bin" or ext == "zip" or ext == "7z" + elseif mode == "save" then + return (ext == "sav" or ext == "srm" or ext == "lua") + elseif mode == "box" then + return (ext == "gci" or ext == "sav") + elseif mode == "mod" then + return (ext == "zip") + elseif mode == "cart" then + return (ext == "g1rcart") + end + return true +end + +local function scanDirectory(dir, mode) + dir = normalizePath(dir) + local entries = {} + + local ok, handle = pcall(io.popen, string.format("ls -1ap %s 2>/dev/null", shQuote(dir))) + if ok and handle then + local output = handle:read("*a") + handle:close() + if output and #output > 0 then + for line in output:gmatch("[^\r\n]+") do + if line ~= "./" and line ~= "../" and line ~= "." and line ~= ".." then + local isDir = (line:sub(-1) == "/") + local cleanName = isDir and line:sub(1, -2) or line + if isMatchingFilter(cleanName, isDir, mode) then + table.insert(entries, { + name = cleanName, + isDir = isDir, + path = (dir == "/" and "/" .. cleanName) or (dir .. "/" .. cleanName), + }) + end + end + end + end + end + + if #entries == 0 and love and love.filesystem and love.filesystem.getDirectoryItems then + local okFs, items = pcall(love.filesystem.getDirectoryItems, dir) + if okFs and items then + for _, name in ipairs(items) do + local full = (dir == "/" and "/" .. name) or (dir .. "/" .. name) + local info = love.filesystem.getInfo and love.filesystem.getInfo(full) + local isDir = info and info.type == "directory" + if isMatchingFilter(name, isDir, mode) then + table.insert(entries, { + name = name, + isDir = isDir, + path = full, + }) + end + end + end + end + + table.sort(entries, function(a, b) + if a.isDir ~= b.isDir then + return a.isDir + end + return a.name:lower() < b.name:lower() + end) + + return entries +end + +function FileBrowser.open(opts) + opts = opts or {} + FileBrowser.active = true + FileBrowser.title = opts.title or "Select File" + FileBrowser.mode = opts.mode or "rom" + FileBrowser.onSelect = opts.onSelect + FileBrowser.onCancel = opts.onCancel + FileBrowser.hint = opts.hint + FileBrowser.selectedIdx = 1 + FileBrowser.page = 1 + + local initPath = opts.initialPath or findSdCardRoot() + FileBrowser.currentDir = initPath + FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode) + if #FileBrowser.entries == 0 and initPath ~= "/" then + FileBrowser.currentDir = "/" + FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode) + end +end + +function FileBrowser.close(path) + if not FileBrowser.active then return end + local onSelect = FileBrowser.onSelect + local onCancel = FileBrowser.onCancel + FileBrowser.active = false + FileBrowser.onSelect = nil + FileBrowser.onCancel = nil + FileBrowser.hint = nil + if path and onSelect then + onSelect(path) + elseif not path and onCancel then + onCancel() + end +end + +function FileBrowser.setDirectory(dir) + FileBrowser.currentDir = normalizePath(dir) + FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode) + FileBrowser.selectedIdx = 1 + FileBrowser.page = 1 +end + +function FileBrowser.cycleShortcut() + FileBrowser.shortcutIdx = (FileBrowser.shortcutIdx % #SHORTCUTS) + 1 + local target = SHORTCUTS[FileBrowser.shortcutIdx] + FileBrowser.setDirectory(target.path) +end + +function FileBrowser.gamepadpressed(button) + if not FileBrowser.active then return false end + + local total = #FileBrowser.entries + local perPage = FileBrowser.perPage + local maxPage = math.max(1, math.ceil(total / perPage)) + + if button == "dpup" then + if FileBrowser.selectedIdx > 1 then + FileBrowser.selectedIdx = FileBrowser.selectedIdx - 1 + FileBrowser.page = math.floor((FileBrowser.selectedIdx - 1) / perPage) + 1 + end + return true + elseif button == "dpdown" then + if FileBrowser.selectedIdx < total then + FileBrowser.selectedIdx = FileBrowser.selectedIdx + 1 + FileBrowser.page = math.floor((FileBrowser.selectedIdx - 1) / perPage) + 1 + end + return true + elseif button == "dpleft" or button == "leftshoulder" or button == "triggerleft" or button == "lefttrigger" or button == "l2" then + if FileBrowser.page > 1 then + FileBrowser.page = FileBrowser.page - 1 + FileBrowser.selectedIdx = (FileBrowser.page - 1) * perPage + 1 + end + return true + elseif button == "dpright" or button == "rightshoulder" or button == "triggerright" or button == "righttrigger" or button == "r2" then + if FileBrowser.page < maxPage then + FileBrowser.page = FileBrowser.page + 1 + FileBrowser.selectedIdx = math.min(total, (FileBrowser.page - 1) * perPage + 1) + end + return true + elseif button == "a" or button == "start" then + local entry = FileBrowser.entries[FileBrowser.selectedIdx] + if entry then + if entry.isDir then + FileBrowser.setDirectory(entry.path) + else + FileBrowser.close(entry.path) + end + end + return true + elseif button == "b" then + if FileBrowser.currentDir ~= "/" and FileBrowser.currentDir ~= "." then + FileBrowser.setDirectory(parentDir(FileBrowser.currentDir)) + else + FileBrowser.close(nil) + end + return true + elseif button == "x" then + FileBrowser.cycleShortcut() + return true + elseif button == "back" or button == "select" then + FileBrowser.close(nil) + return true + end + + return true +end + +function FileBrowser.keypressed(key) + if not FileBrowser.active then return false end + if key == "escape" then + FileBrowser.close(nil) + return true + elseif key == "up" then + return FileBrowser.gamepadpressed("dpup") + elseif key == "down" then + return FileBrowser.gamepadpressed("dpdown") + elseif key == "left" then + return FileBrowser.gamepadpressed("dpleft") + elseif key == "right" then + return FileBrowser.gamepadpressed("dpright") + elseif key == "return" then + return FileBrowser.gamepadpressed("a") + elseif key == "backspace" then + return FileBrowser.gamepadpressed("b") + end + return false +end + +function FileBrowser.draw(m) + if not FileBrowser.active then return end + + local Kit = getKit() + local s = m and m.s or Kit.scale or 1 + local W = (m and m.W) or (love.graphics and love.graphics.getWidth()) or 640 + local H = (m and m.H) or (love.graphics and love.graphics.getHeight()) or 480 + + Theme.fill(0, 0, W, H, PAL.bg, 0.94) + Kit.blockClicks = true + + local pad = math.floor(14 * s) + local modalW = math.floor(math.min(560 * s, W - 20 * s)) + local modalH = math.floor(math.min(420 * s, H - 20 * s)) + local px = math.floor((W - modalW) / 2) + local py = math.floor((H - modalH) / 2) + + Kit.card(px, py, modalW, modalH, true) + + local cy = py + pad + -- Header + Kit.text("button", FileBrowser.title, px + pad, cy, PAL.heading) + Kit.tag(px + modalW - pad - math.floor(60 * s), cy, math.floor(60 * s), math.floor(18 * s), + FileBrowser.mode:upper(), PAL.blue) + + cy = cy + Kit.textHeight("button") + math.floor(6 * s) + + -- Breadcrumb / Current directory bar + local pathBarH = math.floor(24 * s) + Theme.fillRounded(px + pad, cy, modalW - 2 * pad, pathBarH, PAL.bg, 1) + Theme.strokeRounded(px + pad, cy, modalW - 2 * pad, pathBarH, PAL.line, Theme.A.hairline, 1) + local pathStr = Kit.ellipsizeLeft("small", FileBrowser.currentDir, modalW - 4 * pad) + Kit.text("small", pathStr, px + pad + math.floor(6 * s), cy + math.floor(4 * s), PAL.heading) + + cy = cy + pathBarH + math.floor(8 * s) + + if FileBrowser.hint then + cy = cy + Kit.textWrapped("micro", FileBrowser.hint, px + pad, cy, modalW - 2 * pad, + PAL.red, 2) + math.floor(6 * s) + end + + -- File / Directory List + local total = #FileBrowser.entries + local perPage = FileBrowser.perPage + local startIdx = (FileBrowser.page - 1) * perPage + 1 + local endIdx = math.min(total, startIdx + perPage - 1) + local rowH = math.floor(26 * s) + local rowGap = math.floor(3 * s) + + if total == 0 then + Theme.fillRounded(px + pad, cy, modalW - 2 * pad, rowH * 4, PAL.bg, 1) + Kit.textCenter("small", "No files found in this folder.", px + pad, cy + rowH * 1.5, modalW - 2 * pad, PAL.muted) + cy = cy + rowH * 4 + math.floor(8 * s) + else + for i = startIdx, endIdx do + local entry = FileBrowser.entries[i] + local isSel = (FileBrowser.selectedIdx == i) + local rx = px + pad + local rw = modalW - 2 * pad + + if Kit.press(rx, cy, rw, rowH) then + FileBrowser.selectedIdx = i + if entry.isDir then + FileBrowser.setDirectory(entry.path) + else + FileBrowser.close(entry.path) + end + end + + if isSel then + Theme.fillRounded(rx, cy, rw, rowH, PAL.ink, 1) + local icon = entry.isDir and "[DIR] " or "[FILE] " + Kit.text("small", icon .. entry.name, rx + math.floor(8 * s), cy + math.floor(4 * s), PAL.inverse) + else + Theme.fillRounded(rx, cy, rw, rowH, PAL.surface, 1) + Theme.strokeRounded(rx, cy, rw, rowH, PAL.line, Theme.A.hairline, 1) + local icon = entry.isDir and "[DIR] " or "[FILE] " + local col = entry.isDir and PAL.blue or PAL.text + Kit.text("small", icon .. entry.name, rx + math.floor(8 * s), cy + math.floor(4 * s), col) + end + + cy = cy + rowH + rowGap + end + end + + -- Pager info & Controls guide + local bottomY = py + modalH - pad - math.floor(18 * s) + local pageInfo = string.format("Page %d of %d (%d items)", FileBrowser.page, math.max(1, math.ceil(total / perPage)), total) + Kit.text("micro", pageInfo, px + pad, bottomY, PAL.muted) + + local guide = "A: Open/Select B: Parent X: Shortcut Start: Confirm Select: Cancel" + Kit.textRight("micro", guide, px + modalW - pad, bottomY, PAL.muted) + + Kit.blockClicks = false +end + +return FileBrowser diff --git a/src/ui/kit/Icons.lua b/src/ui/kit/Icons.lua new file mode 100644 index 000000000..d90fae3d7 --- /dev/null +++ b/src/ui/kit/Icons.lua @@ -0,0 +1,80 @@ +-- Original native vector artwork by Roxas2712. No Gen1 launcher atlas. +-- Geometry is expressed on a 24-unit canvas and drawn at the caller's scale. +local Icons={NAMES={},ATLASES={}} +local shapes={ + ['x']={{'line',5,5,19,19},{'line',19,5,5,19}}, + ['check']={{'line',4,12,9,17,20,6}}, + ['plus']={{'line',4,12,20,12},{'line',12,4,12,20}}, + ['minus']={{'line',4,12,20,12}}, + ['chevron-left']={{'line',15,5,8,12,15,19}}, + ['chevron-right']={{'line',9,5,16,12,9,19}}, + ['chevron-up']={{'line',5,15,12,8,19,15}}, + ['chevron-down']={{'line',5,9,12,16,19,9}}, + ['arrow-left']={{'line',20,12,4,12,10,6},{'line',4,12,10,18}}, + ['arrow-right']={{'line',4,12,20,12,14,6},{'line',20,12,14,18}}, + ['arrow-up']={{'line',12,20,12,4,6,10},{'line',12,4,18,10}}, + ['arrow-down']={{'line',12,4,12,20,6,14},{'line',12,20,18,14}}, + ['arrow-up-down']={{'line',8,20,8,4,4,8},{'line',8,4,12,8},{'line',16,4,16,20,12,16},{'line',16,20,20,16}}, + ['arrow-left-right']={{'line',21,8,3,8,7,4},{'line',3,8,7,12},{'line',3,16,21,16,17,12},{'line',21,16,17,20}}, + ['chevrons-up']={{'line',5,12,12,5,19,12},{'line',5,19,12,12,19,19}}, + ['folder']={{'line',3,19,3,5,10,5,12,8,21,8,21,19,3,19}}, + ['folder-open']={{'line',3,18,3,5,10,5,12,8,20,8,20,11},{'line',3,18,7,11,22,11,18,19,3,19}}, + ['save']={{'rectangle','line',4,3,16,18},{'rectangle','line',8,3,8,6},{'rectangle','line',8,14,8,7}}, + ['download']={{'line',12,3,12,15,7,10},{'line',12,15,17,10},{'line',4,16,4,21,20,21,20,16}}, + ['upload']={{'line',12,16,12,4,7,9},{'line',12,4,17,9},{'line',4,16,4,21,20,21,20,16}}, + ['trash']={{'line',4,6,20,6},{'line',8,6,8,3,16,3,16,6},{'line',6,6,7,21,17,21,18,6},{'line',10,9,10,17},{'line',14,9,14,17}}, + ['pencil']={{'line',4,20,5,15,17,3,21,7,9,19,4,20},{'line',14,6,18,10}}, + ['paintbrush']={{'line',10,14,19,3,22,6,13,17,10,14},{'line',10,14,6,14,4,18,2,20,8,21,12,18,13,17}}, + ['file-pen-line']={{'line',16,8,16,3,4,3,4,21,12,21},{'line',12,3,16,7,12,7,12,3},{'line',12,20,13,16,20,9,23,12,16,19,12,20}}, + ['copy']={{'rectangle','line',3,3,12,12},{'rectangle','line',8,8,13,13}}, + ['undo-2']={{'line',3,11,3,5,9,5},{'line',3,10,8,6,15,6,20,10,20,15,16,19,10,19}}, + ['redo-2']={{'line',21,11,21,5,15,5},{'line',21,10,16,6,9,6,4,10,4,15,8,19,14,19}}, + ['rotate-ccw']={{'line',3,10,3,4,9,4},{'line',3,9,8,4,15,4,20,9,20,15,15,20,8,20,4,16}}, + ['ellipsis']={{'circle','fill',5,12,1.5},{'circle','fill',12,12,1.5},{'circle','fill',19,12,1.5}}, + ['lock']={{'rectangle','line',5,10,14,11},{'line',8,10,8,5,10,3,14,3,16,5,16,10},{'line',12,14,12,17}}, + ['lock-open']={{'rectangle','line',5,10,14,11},{'line',8,10,8,5,10,3,14,3,16,5},{'line',12,14,12,17}}, + ['mail']={{'rectangle','line',3,5,18,14},{'line',3,5,12,13,21,5}}, + ['search']={{'circle','line',10,10,6},{'line',15,15,21,21}}, + ['globe']={{'circle','line',12,12,9},{'line',3,12,21,12},{'line',12,3,8,8,8,16,12,21,16,16,16,8,12,3}}, + ['settings']={{'circle','line',12,12,4},{'line',9,3,15,3,15,6,18,8,21,8,21,15,18,15,16,18,16,21,9,21,9,18,6,16,3,16,3,9,6,9,9,6,9,3}}, + ['puzzle']={{'line',4,4,10,4,10,2,14,2,14,4,20,4,20,10,22,10,22,14,20,14,20,20,14,20,14,17,10,17,10,20,4,20,4,14,7,14,7,10,4,10,4,4}}, + ['heart']={{'line',12,21,3,12,3,6,7,3,12,7,17,3,21,6,21,12,12,21}}, + ['package']={{'line',3,7,12,3,21,7,21,18,12,22,3,18,3,7,12,11,21,7},{'line',12,11,12,22},{'line',7,5,16,9}}, + ['backpack']={{'rectangle','line',5,6,14,16},{'rectangle','line',8,13,8,6},{'line',9,6,9,3,15,3,15,6}}, + ['list-filter']={{'line',3,5,21,5},{'line',6,11,18,11},{'line',10,17,14,17}}, + ['eye']={{'line',2,12,7,6,17,6,22,12,17,18,7,18,2,12},{'circle','line',12,12,3}}, + ['award']={{'circle','line',12,9,6},{'line',7,14,6,22,12,19,18,22,17,14}}, + ['book-open']={{'line',12,21,3,18,3,3,12,6,21,3,21,18,12,21,12,6}}, + ['shield-check']={{'line',12,2,21,6,19,17,12,22,5,17,3,6,12,2},{'line',8,12,11,15,16,9}}, + ['map-pin']={{'line',12,22,5,12,5,6,9,2,15,2,19,6,19,12,12,22},{'circle','line',12,8,3}}, + ['grid-2x2']={{'rectangle','line',3,3,7,7},{'rectangle','line',14,3,7,7},{'rectangle','line',3,14,7,7},{'rectangle','line',14,14,7,7}}, + ['sliders-horizontal']={{'line',3,6,21,6},{'line',3,12,21,12},{'line',3,18,21,18},{'circle','fill',8,6,2},{'circle','fill',16,12,2},{'circle','fill',10,18,2}}, + ['expand']={{'line',3,9,3,3,9,3},{'line',15,3,21,3,21,9},{'line',21,15,21,21,15,21},{'line',9,21,3,21,3,15}}, + ['users']={{'circle','line',9,7,4},{'circle','line',18,8,3},{'line',2,21,2,16,5,13,13,13,16,16,16,21},{'line',17,13,22,16,22,21}}, + ['user-round']={{'circle','line',12,7,4},{'line',4,21,4,16,8,12,16,12,20,16,20,21}}, + ['wallet']={{'rectangle','line',3,5,18,15},{'rectangle','line',15,10,7,6},{'circle','fill',18,13,1}}, + ['flag']={{'line',5,22,5,3,13,3,13,5,21,5,20,14,12,14,12,12,5,12}}, + ['sparkles']={{'line',12,2,14,9,21,11,14,13,12,20,10,13,3,11,10,9,12,2},{'line',20,1,20,5},{'line',18,3,22,3}}, + ['shuffle']={{'line',3,5,7,5,17,19,21,19,18,16},{'line',21,19,18,22},{'line',3,19,7,19,17,5,21,5,18,2},{'line',21,5,18,8}}, + ['triangle-alert']={{'line',12,2,23,21,1,21,12,2},{'line',12,8,12,14},{'circle','fill',12,18,1}}, + ['circle-help']={{'circle','line',12,12,10},{'line',8,8,9,6,14,6,16,8,16,10,12,13,12,15},{'circle','fill',12,18,1}}, + ['music']={{'line',8,18,8,5,20,3,20,16},{'line',8,8,20,6},{'circle','fill',5,18,3},{'circle','fill',17,16,3}}, + ['play']={{'polygon','fill',6,3,21,12,6,21}}, + ['square']={{'rectangle','fill',4,4,16,16}}, + ['monitor']={{'rectangle','line',2,3,20,14},{'line',12,17,12,22},{'line',7,22,17,22}}, + ['gamepad-2']={{'line',5,6,19,6,23,18,20,21,15,15,9,15,4,21,1,18,5,6},{'line',5,10,11,10},{'line',8,7,8,13},{'circle','fill',16,10,1},{'circle','fill',19,12,1}}, + ['chart-no-axes-column']={{'rectangle','line',3,12,4,9},{'rectangle','line',10,3,4,18},{'rectangle','line',17,8,4,13}}, +} +for name in pairs(shapes)do Icons.NAMES[#Icons.NAMES+1]=name end +table.sort(Icons.NAMES) +function Icons.has(name)return shapes[name]~=nil end +function Icons.draw(name,x,y,size,color,alpha) + local commands=shapes[name] + if not commands then return false end + local g=love.graphics + g.push('all');g.translate(x,y);g.scale(size/24,size/24) + g.setLineWidth(1.6);g.setColor(color[1]/255,color[2]/255,color[3]/255,alpha or 1) + for _,command in ipairs(commands)do g[command[1]](unpack(command,2)) end + g.pop();return true +end +return Icons diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua new file mode 100644 index 000000000..dae04314c --- /dev/null +++ b/src/ui/kit/Kit.lua @@ -0,0 +1,1282 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Historical performance figures below describe the original Gen1 kit; +-- this native integration makes no corresponding FPS/latency claim. +-- Immediate-mode widget kit shared by the launcher and the save editor. +-- +-- This is the replacement for the vendored FlexLove tree that the launcher +-- used to rebuild every frame. The contract that mattered there is kept -- +-- the UI is rebuilt from owner state each frame, so it can never drift -- +-- but without a retained element tree, per-element id hashing, or a +-- property snapshot pass. Measured effect on the launcher's build+draw: +-- ~9.2ms/frame down to well under 1ms (see POKEPORT_LAUNCHER_PROF). +-- +-- Usage, once per love.draw(): +-- Kit.layout(w, h) -- fonts + scale, only on resize +-- Kit.beginFrame(mx, my, clicked, wheel) +-- ... widgets ... +-- Kit.endFrame() +-- +-- WHY IT IS FAST (the rules any new widget must follow): +-- 1. No allocation in the steady state. Widgets take and return scalars; +-- the per-frame tables that do exist (nav list, audit) are reused and +-- truncated, never rebuilt. LuaJIT's GC is the difference between a +-- 6ms frame and a 0.6ms one when a list has 200 rows. +-- 2. Text is cached as love.graphics.Text objects keyed by font+string +-- (Kit.text). G.print re-shapes the string every call; a Text object +-- shapes once and then costs one batched draw. Colour is applied at +-- draw time, which does NOT break the batch -- switching FONTS does, +-- which is the other reason the cache pays. +-- 3. Measurement (font:getWidth, ellipsize) is memoised per font+string. +-- Ellipsising is O(glyphs) with a getWidth per step and list rows do it +-- for every visible cell, every frame, on strings that never change. +-- 4. Lists PAGINATE. Row count is bounded by the page size, so a 500-mod +-- index costs exactly what a 10-mod one does. There is no virtualised +-- scroller and no momentum integrator to run. +-- 5. Draw flat. No stencil, no mesh, no canvas, no shader, no blend-mode +-- change -- every one is a pipeline flush. Rounded corners, the emboss +-- and a card's drop shadow are allowed because they only add VERTICES at +-- the same pipeline state (see Theme.lua's header for the full rule). +-- +-- ACCESSIBILITY / INPUT: every control is reachable four ways -- mouse, +-- touch (>= 30px targets), keyboard (spatial focus ring, arrows + Enter), +-- and gamepad (the same ring, driven by the d-pad, plus a virtual cursor). +-- Hit testing is a plain rect with no z-order, so overlapping layers must be +-- drawn in dispatch order and a modal raises Kit.blockClicks over what it +-- covers. + +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL +local VirtualKeyboard = require("src.ui.kit.VirtualKeyboard") +local FileBrowser = require("src.ui.kit.FileBrowser") +local Transition = require("src.ui.kit.Transition") + +local Kit = { + scale = 1, + time = 0, + focus = nil, -- active text-input id (nil = no focused field) + focusId = nil, -- spatial-nav ring id (nil = nothing selected by pad/arrows) + VirtualKeyboard = VirtualKeyboard, + FileBrowser = FileBrowser, + Transition = Transition, +} + +Kit.mouseX, Kit.mouseY = 0, 0 +Kit.mouseClicked = false -- left button pressed this frame +Kit.mouseDown = false -- held, polled (drag / press-and-hold) +Kit.wheelY = 0 -- wheel notches queued since the last frame +Kit.scale = 1 +Kit.blockClicks = false +Kit.audit = nil + +local G = love and love.graphics or nil +local edits = {} -- queued textinput / backspace since the last frame +local kbField = nil -- id of the field the soft keyboard is raised for + +local function has(name) + return Theme.probe(name) +end + +-- ------------------------------------------------------------ soft keyboard +-- Mobile LOVE only delivers love.textinput while setTextInput(true) is +-- active, and that call is what raises the Android/iOS soft keyboard; the +-- rect keeps the focused field visible above it. setTextInput is global SDL +-- state, not per-widget, so desktop text input is never turned off (#529). +local function mobile() + local osName = love and love.system and love.system.getOS + and love.system.getOS() + return osName == "Android" or osName == "iOS" +end +Kit.isMobile = mobile + +local function syncSoftKeyboard(id, x, y, w, h) + if not (love and love.keyboard and love.keyboard.setTextInput) then return end + if id then + if kbField ~= id then + kbField = id + love.keyboard.setTextInput(true, math.floor(x), math.floor(y), + math.ceil(w), math.ceil(h)) + end + elseif kbField then + kbField = nil + if mobile() then love.keyboard.setTextInput(false) end + end +end + +-- ------------------------------------------------------------- text caching +-- Two caches, both keyed by font name + string, both cleared wholesale when +-- the font set is rebuilt (a resize). A wholesale clear is correct and +-- cheap: an LRU would cost more bookkeeping per lookup than it saves, and +-- the working set of a UI is small and stable between resizes. +local textCache, textCacheN = {}, 0 +local widthCache = {} +local ellipsisCache = {} +local CACHE_MAX = 1024 + +local wrapCacheRef -- forward declaration; the table is defined below +local function clearCaches() + textCache, textCacheN = {}, 0 + widthCache = {} + ellipsisCache = {} + if wrapCacheRef then + for k in pairs(wrapCacheRef) do wrapCacheRef[k] = nil end + end +end +Kit.clearCaches = clearCaches + +local function font(name) + return Kit.fonts[name] or Kit.fonts.small +end +Kit.font = font + +-- Rebuild the font set when the window size changes. The scale never dips +-- below 0.9 so text and the 30px tap targets stay readable on a phone; a +-- narrow window is answered by REFLOW (see Layout.lua), never by shrinking. +-- Global size multiplier. Everything in the UI derives from Kit.scale, so +-- one factor here moves text, tap targets, padding and row heights together +-- and nothing drifts out of proportion. 1.3 because the launcher is read at +-- couch distance as often as at desk distance, and the old sizing was tuned +-- for the latter only. +local UI_SCALE = 1.3 + +function Kit.layout(width, height) + local s = Theme.clamp(math.min(width / 640, height / 768), 0.9, 1.6) * UI_SCALE + -- Two numbers, not a formatted key: this runs once per frame and the + -- string:format allocated on every one of them. + local kw, kh = math.floor(width), math.floor(height) + if Kit._fontW ~= kw or Kit._fontH ~= kh then + Kit._fontW, Kit._fontH = kw, kh + Kit.fonts = Theme.fonts(s) + clearCaches() -- every cached Text/width belongs to the old font set + end + Kit.scale = s + Kit.width, Kit.height = width, height + return s +end + +function Kit.textWidth(name, str) + str = tostring(str) + local key = name .. "\0" .. str + local w = widthCache[key] + if w then return w end + local f = font(name) + -- Never let a malformed string (a mod name from a third-party index) throw + -- out of a measurement: an unmeasurable string is treated as zero-width and + -- the ellipsis logic clips it away. + if f then + local ok, got = pcall(f.getWidth, f, str) + w = ok and got or 0 + else + w = 0 + end + widthCache[key] = w + return w +end + +function Kit.textHeight(name) + local f = font(name) + return f and f:getHeight() or 12 +end + +function Kit.ellipsize(name, str, maxW) + str = tostring(str or "") + local key = name .. "\0" .. math.floor(maxW) .. "\0" .. str + local c = ellipsisCache[key] + if c then return c end + c = Theme.ellipsize(font(name), str, maxW) + ellipsisCache[key] = c + return c +end + +function Kit.ellipsizeLeft(name, str, maxW) + str = tostring(str or "") + local key = name .. "\1" .. math.floor(maxW) .. "\0" .. str + local c = ellipsisCache[key] + if c then return c end + c = Theme.ellipsizeLeft(font(name), str, maxW) + ellipsisCache[key] = c + return c +end + +-- A cached, pre-shaped Text object. Falls back to G.print under a stub or +-- when the cache is saturated. +local function textObject(name, str) + if not (G and has("newText")) then return nil end + local key = name .. "\0" .. str + local t = textCache[key] + if t then return t end + if textCacheN >= CACHE_MAX then clearCaches() end + local f = font(name) + if not f then return nil end + local ok, obj = pcall(G.newText, f, str) + if not ok then return nil end + textCache[key] = obj + textCacheN = textCacheN + 1 + return obj +end + +-- Bold text: the same cached run drawn twice, one pixel apart. The UI face +-- has a single weight, so this is the only way to get emphasis without +-- shipping a second font -- and it keeps the measurement identical, which +-- matters because every layout here is measured, not flowed. +function Kit.textBold(name, str, x, y, c, a) + local w = Kit.text(name, str, x, y, c, a) + Kit.text(name, str, x + Theme.BOLD_OFFSET, y, c, a) + return w +end + +function Kit.textCenterBold(name, str, x, y, w, c, a) + local tw = Kit.textWidth(name, tostring(str)) + return Kit.textBold(name, str, x + (w - tw) / 2, y, c, a) +end + +-- Draw a string. Returns its width, so callers can lay out inline runs +-- without a second measurement. +function Kit.text(name, str, x, y, c, a) + if not G then return 0 end + str = tostring(str) + Theme.col(c or PAL.text, a or 1) + local obj = textObject(name, str) + if obj then + G.draw(obj, Theme.snap(x), Theme.snap(y)) + else + local f = font(name) + if not f then return 0 end + G.setFont(f) + -- Same guard as the measurement path: a string LOVE cannot shape must + -- not take the whole frame down with it. + pcall(G.print, str, Theme.snap(x), Theme.snap(y)) + end + return Kit.textWidth(name, str) +end + +function Kit.textRight(name, str, x2, y, c, a) + return Kit.text(name, str, x2 - Kit.textWidth(name, tostring(str)), y, c, a) +end + +function Kit.textCenter(name, str, x, y, w, c, a) + return Kit.text(name, str, x + (w - Kit.textWidth(name, tostring(str))) / 2, + y, c, a) +end + +-- Word-wrapped text. Font:getWrap re-shapes the whole string every call and +-- list rows ask for the same (font, width, string) every frame, so the line +-- split is memoised alongside the other measurement caches. `maxLines` +-- truncates with an ellipsis rather than overflowing the box the caller +-- reserved -- an immediate-mode layout has no way to grow after the fact. +local wrapCache = {} +wrapCacheRef = wrapCache + +function Kit.wrapLines(name, str, w) + str = tostring(str or "") + if str == "" or w <= 0 then return nil end + local key = name .. "\0" .. math.floor(w) .. "\0" .. str + local lines = wrapCache[key] + if lines then return lines end + local f = font(name) + if not f then return nil end + local ok, _, wrapped = pcall(f.getWrap, f, str, w) + lines = (ok and wrapped) or { str } + wrapCache[key] = lines + return lines +end + +-- Returns the height consumed. +function Kit.textWrapped(name, str, x, y, w, c, maxLines, a) + local lines = Kit.wrapLines(name, str, w) + if not lines then return 0 end + local lh = Kit.textHeight(name) + local n = #lines + if maxLines and n > maxLines then n = maxLines end + for i = 1, n do + local line = lines[i] + if maxLines and i == maxLines and #lines > maxLines then + line = Kit.ellipsize(name, line .. "...", w) + end + Kit.text(name, line, x, y + (i - 1) * lh, c, a) + end + return n * lh +end + +-- Height a wrapped run will need, without drawing it. Panels call this to +-- reserve space before laying the block out. +function Kit.wrapHeight(name, str, w, maxLines) + local lines = Kit.wrapLines(name, str, w) + if not lines then return 0 end + local n = #lines + if maxLines and n > maxLines then n = maxLines end + return n * Kit.textHeight(name) +end + +-- 12px / 2px-tracked uppercase section caption -- the design's one and only +-- section header. Returns its height so callers can stack below. +function Kit.caption(x, y, str, c) + if not G then return Kit.textHeight("caption") end + local f = font("caption") + if not f then return 12 end + G.setFont(f) + Theme.col(c or PAL.caption, 1) + Theme.spaced(f, str, Theme.snap(x), Theme.snap(y), 2 * Kit.scale) + return f:getHeight() +end + +function Kit.captionWidth(str) + return Theme.spacedWidth(font("caption"), str, 2 * Kit.scale) +end + +-- ------------------------------------------------------------- frame cycle +function Kit.beginFrame(mx, my, clicked, wheel) + Kit.mouseX, Kit.mouseY = mx or 0, my or 0 + Kit.mouseClicked = clicked and true or false + Kit.wheelY = wheel or 0 + Kit._fieldHit, Kit._focusDrawn = false, false + local down = false + if love and love.mouse and love.mouse.isDown then + down = love.mouse.isDown(1) and true or false + end + Kit.mouseDown = down + if not down then Kit._drag = nil end + Kit.resetClip() + Kit.blockClicks = false + Kit.occlude(nil) + if love and love.timer and love.timer.getTime then + Kit.time = love.timer.getTime() + end + Transition.update(Kit.time) + if Transition.active() then Kit.blockClicks = true end + -- Resolve any queued focus-ring movement against LAST frame's geometry. + -- Immediate mode has no geometry until the frame is built, and the ring + -- must move before widgets test themselves against it. + Kit._resolveNav() + -- Start collecting this frame's focusables. + Kit._navN = 0 +end + +local function getNavLayer(slot) + if not slot then return 3 end + local id = tostring(slot.id or "") + local y = slot.y or 0 + + -- Layer 1: Top Bar (Settings / Gear, Close / Quit, Save Sync). tab-sync + -- rides the gear's cluster; by its tab- prefix alone it fell into layer 2, + -- so Right from it found nothing and Up jumped sideways to the gear. + if id == "gear" or id == "settings" or id == "close" or id == "quit" + or id == "tab-sync" + or (y < 45 * Kit.scale and not id:match("^tab%-")) then + return 1 + end + + -- Layer 2: ROM Select & Feature Tabs (Red, Blue, Yellow, Gold, Silver, Crystal, Mods, Find, Skins, Bug) + if id:match("^tab%-") or (y >= 45 * Kit.scale and y < 105 * Kit.scale and (slot.h and slot.h < 50 * Kit.scale)) then + return 2 + end + + -- Layer 4: Footer (Patch notes, Updater, BCG, Export, etc.) + local screenH = (love and love.graphics and love.graphics.getHeight and love.graphics.getHeight()) or 480 + if id == "patch-notes" or id == "updater" or id == "bcg" + or id:match("^footer") or (y > screenH - 55 * Kit.scale) then + return 4 + end + + -- Layer 3: Main content panel (Import ROM / Play, Manage, Save slots, Mods list, Find mods, etc.) + return 3 +end + +-- Retire this frame's keystrokes, wheel notches and one-shot activations. +-- Anything typed while no field had focus is dropped here rather than +-- replayed into the next field that gets clicked. +function Kit.endFrame() + if Kit.focus and ((Kit.mouseClicked and not Kit._fieldHit) or not Kit._focusDrawn) then Kit.blur() end + for i = #edits, 1, -1 do edits[i] = nil end + Kit.wheelY = 0 + Kit._activateId = nil + -- This frame's focusables become next frame's navigation graph. + local n = Kit._navN or 0 + Kit._navPrevN = n + -- If the focused id vanished or not set yet, park the ring on Layer 3 (Import ROM / Play) + if (not Kit.focusId or not Kit._navSeen[Kit.focusId]) and n > 0 then + -- 1. Prefer ROM Action (Layer 3) + local chosen = nil + for i = 1, n do + local slot = Kit._nav[i] + if slot and slot.id and tostring(slot.id):match("^rom%-") then + chosen = slot.id; break + end + end + -- 2. Prefer any Layer 3 control + if not chosen then + for i = 1, n do + local slot = Kit._nav[i] + if slot and getNavLayer(slot) == 3 then + chosen = slot.id; break + end + end + end + Kit.focusId = chosen or (Kit._nav[1] and Kit._nav[1].id) or nil + end + for k in pairs(Kit._navSeen) do Kit._navSeen[k] = nil end +end + +-- ------------------------------------------------------------ focus ring +-- Spatial navigation. Every focusable control registers its rect as it +-- draws; a queued direction picks the nearest candidate in that direction +-- from the previous frame's set. +Kit._nav = {} +Kit._navN = 0 +Kit._navPrevN = 0 +Kit._navSeen = {} +Kit._navQueue = nil +Kit._activateId = nil +Kit._ringShown = true + +-- Register a focusable. Returns true when it currently holds the ring. +function Kit.focusable(id, x, y, w, h) + if Kit.blockClicks then return false end + local n = (Kit._navN or 0) + 1 + Kit._navN = n + local slot = Kit._nav[n] + if not slot then slot = {}; Kit._nav[n] = slot end + slot.id, slot.x, slot.y, slot.w, slot.h = id, x, y, w, h + Kit._navSeen[id] = true + if Kit.focusId == nil and tostring(id):match("^rom%-") then + Kit.focusId = id + end + return Kit._ringShown and Kit.focusId == id +end + +function Kit.navigate(dir) + Kit._navQueue = dir + Kit._ringShown = true +end + +function Kit.activateFocused() + if Kit.focusId then Kit._activateId = Kit.focusId end +end + +function Kit.setFocus(id) + Kit.focusId = id + Kit._ringShown = id ~= nil +end + +function Kit.pointerUsed() + Kit._ringShown = false +end + +function Kit._resolveNav() + local dir = Kit._navQueue + Kit._navQueue = nil + local n = Kit._navPrevN or 0 + if not dir or n == 0 then return end + local cur + for i = 1, n do + if Kit._nav[i].id == Kit.focusId then cur = Kit._nav[i]; break end + end + if not cur then + Kit.focusId = Kit._nav[1].id + return + end + + local curLayer = getNavLayer(cur) + local cx, cy = cur.x + cur.w / 2, cur.y + cur.h / 2 + + if tostring(cur.id):match("^gamepop%-") then + local best, bestScore + for i = 1, n do + local c = Kit._nav[i] + if c.id ~= cur.id and tostring(c.id):match("^gamepop%-") then + local dx, dy = c.x + c.w / 2 - cx, c.y + c.h / 2 - cy + local horizontal = dir == "left" or dir == "right" + local forward = (dir == "left" and -dx) or (dir == "right" and dx) + or (dir == "up" and -dy) or dy + local cross = math.abs(horizontal and dy or dx) + if forward > 1 and (not horizontal or cross < 1) then + local score = forward + cross * 2 + if not bestScore or score < bestScore then + best, bestScore = c, score + end + end + end + end + if best then Kit.focusId = best.id end + return + end + + if dir == "left" or dir == "right" then + -- STRICT SAME-LAYER HORIZONTAL NAVIGATION (Left/Right NEVER jumps between layers) + local best, bestDx + for i = 1, n do + local c = Kit._nav[i] + if c.id ~= cur.id and getNavLayer(c) == curLayer then + local cMidX = c.x + c.w / 2 + local dx = cMidX - cx + if dir == "left" and dx < -1 then + local dist = -dx + if not bestDx or dist < bestDx then + best, bestDx = c, dist + end + elseif dir == "right" and dx > 1 then + local dist = dx + if not bestDx or dist < bestDx then + best, bestDx = c, dist + end + end + end + end + if best then + Kit.focusId = best.id + return + end + return + elseif dir == "up" or dir == "down" then + -- Within the layer first: the nearest control above/below in the same + -- layer wins, so Down from the cart reaches the Scan / Import button under + -- it instead of jumping over it to the footer. The horizontal gap weighs + -- double so a control straight below beats one off to the side. + do + local best, bestScore + for i = 1, n do + local c = Kit._nav[i] + if c.id ~= cur.id and getNavLayer(c) == curLayer then + local dy = (c.y + c.h / 2) - cy + local forward = dir == "down" and dy or -dy + if forward > 1 then + local gap = math.max(0, c.x - (cur.x + cur.w), cur.x - (c.x + c.w)) + local score = forward + gap * 2 + if not bestScore or score < bestScore then + best, bestScore = c, score + end + end + end + end + if best then + Kit.focusId = best.id + return + end + end + -- VERTICAL LAYER NAVIGATION (Up/Down steps between layers: 1 <-> 2 <-> 3 <-> 4) + local targetLayer = dir == "up" and (curLayer - 1) or (curLayer + 1) + targetLayer = math.max(1, math.min(4, targetLayer)) + + local best, bestScore + for i = 1, n do + local c = Kit._nav[i] + if c.id ~= cur.id then + local l = getNavLayer(c) + if (dir == "up" and l < curLayer) or (dir == "down" and l > curLayer) then + local layerDiff = math.abs(l - targetLayer) + local cMidX = c.x + c.w / 2 + local xDist = math.abs(cMidX - cx) + local score = layerDiff * 10000 + xDist + if not bestScore or score < bestScore then + best, bestScore = c, score + end + end + end + end + if best then + Kit.focusId = best.id + return + end + end +end + +-- ------------------------------------------------------------ input plumbing +function Kit.textinput(text) + if VirtualKeyboard.active then + return VirtualKeyboard.textinput(text) + end + if not Kit.focus then return false end + edits[#edits + 1] = text + return true +end + +-- Returns true when the key was consumed, so the host can leave its own +-- shortcuts alone while the user is typing or driving the ring. +function Kit.keypressed(key) + if VirtualKeyboard.active then + return VirtualKeyboard.keypressed(key) + end + if FileBrowser.active then + return FileBrowser.keypressed(key) + end + if Kit.focus then + if key == "backspace" then edits[#edits + 1] = "\b" return true + elseif key == "return" or key == "kpenter" or key == "escape" then + edits[#edits + 1] = "\r" return true + elseif key == "tab" or key == "up" or key == "down" then + Kit.blur() + Kit.navigate(key == "up" and "up" or "down") + return true + end + -- printable keys arrive through textinput; everything else falls through + return false + end + if key == "up" or key == "down" or key == "left" or key == "right" then + Kit.navigate(key) + return true + elseif key == "return" or key == "kpenter" or key == "space" then + Kit.activateFocused() + return true + end + return false +end + +-- Gamepad d-pad / stick, routed by the host's pad handling. +function Kit.gamepadpressed(button) + local GamepadMap = require("src.core.GamepadMap") + local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button + if VirtualKeyboard.active then + return VirtualKeyboard.gamepadpressed(action) + end + if FileBrowser.active then + return FileBrowser.gamepadpressed(action) + end + if Kit.focus and action == "b" then Kit.blur() return true end + if action == "dpup" then Kit.navigate("up") return true + elseif action == "dpdown" then Kit.navigate("down") return true + elseif action == "dpleft" then Kit.navigate("left") return true + elseif action == "dpright" then Kit.navigate("right") return true + elseif action == "a" then Kit.activateFocused() return true end + return false +end + +function Kit.blur() + Kit.focus = nil + syncSoftKeyboard(nil) +end + +-- -------------------------------------------------------------- hit testing +local occluderBoxes = {} +Kit._occluders = 0 +Kit._occluder = nil +Kit._overlay = false + +function Kit.occlude(x, y, w, h) + if x == nil then + Kit._occluders = 0 + Kit._occluder = nil + return + end + local n = Kit._occluders + 1 + local o = occluderBoxes[n] + if not o then + o = {} + occluderBoxes[n] = o + end + o.x, o.y, o.w, o.h = x, y, w, h + Kit._occluders = n + Kit._occluder = occluderBoxes[1] +end + +function Kit.occluded() + if Kit._occluders == 0 or Kit._overlay then return false end + local mx, my = Kit.mouseX, Kit.mouseY + for i = 1, Kit._occluders do + local o = occluderBoxes[i] + if mx >= o.x and mx <= o.x + o.w and my >= o.y and my <= o.y + o.h then + return true + end + end + return false +end + +-- A widget inside a clip region can sit at coordinates outside the visible +-- rect, so the active clip bounds the hit: what the user cannot see cannot +-- take the tap. +function Kit.hit(x, y, w, h) + local c = Kit._clipRect + if c and not (Kit.mouseX >= c.x and Kit.mouseX <= c.x + c.w + and Kit.mouseY >= c.y and Kit.mouseY <= c.y + c.h) then + return false + end + if Kit._occluders > 0 and Kit.occluded() then return false end + return Kit.mouseX >= x and Kit.mouseX <= x + w + and Kit.mouseY >= y and Kit.mouseY <= y + h +end + +function Kit.hover(x, y, w, h) + -- Shielded widgets (drawn while a modal owns the frame) must not glow + -- either: a hover highlight under the scrim reads as "still clickable". + if Kit.blockClicks then return false end + return Kit.hit(x, y, w, h) +end + +function Kit.press(x, y, w, h) + if Kit.blockClicks then return false end + return Kit.mouseClicked and Kit.hit(x, y, w, h) +end + +-- Layout audit: when a test sets Kit.audit to a table, every control that +-- could take a click this frame appends its rect (plus the clip that bounds +-- it), so a window-size sweep can assert no two controls overlap and none +-- escapes the window. Shielded widgets are skipped: under a modal they +-- cannot take the tap, and the modal legitimately covers them. +local function audit(class, x, y, w, h, label) + local a = Kit.audit + if not a or Kit.blockClicks then return end + local c = Kit._clipRect + a[#a + 1] = { class = class, x = x, y = y, w = w, h = h, + label = tostring(label or ""), + clip = c and { x = c.x, y = c.y, w = c.w, h = c.h } or nil } +end +Kit._audit = audit + +-- ------------------------------------------------------------------ metrics +-- Minimum tap target. 30px at scale 1 (up from the editor's 26) because the +-- launcher is the first thing a phone user touches and these are the only +-- controls that matter. +function Kit.tapMin() return math.floor(30 * Kit.scale) end + +-- ---------------------------------------------------------------- surfaces +function Kit.card(x, y, w, h, variant) + Theme.card(x, y, w, h, variant) +end + +function Kit.row(x, y, w, h, selected, id) + audit("row", x, y, w, h, id or "row") + local focused = id and Kit.focusable(id, x, y, w, h) or false + local hot = Kit.hover(x, y, w, h) + local state = selected and "selected" or (hot and "hover" or nil) + local ink = Theme.row(x, y, w, h, state) + -- The focus ring is a second inset outline, so it reads on both a black + -- row and a white selected one. + if focused then + Theme.strokeRounded(x, y, w, h, PAL.ink, 1, 2, Theme.radius()) + end + local clicked = Kit.press(x, y, w, h) + or (id ~= nil and Kit._activateId == id) + return clicked, ink +end + +-- Empty-state box: hairline outline and a centred hint. (The old dashed +-- border sampled a rounded path into a polyline every frame; a solid +-- hairline says the same thing for one rect.) +function Kit.emptyBox(x, y, w, h, message) + if not G then return end + Theme.card(x, y, w, h, "empty") + Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale), + x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted) +end + +-- ----------------------------------------------------------------- buttons +-- Primary actions carry color; secondary and destructive actions use a quiet +-- surface and an outline. All faces share an inset hover/press highlight and +-- a solid keyboard focus border, so controls never grow into their neighbors. +local Button = require("src.ui.kit.Button") +local Icons = require("src.ui.kit.Icons") +Kit.KINDS = Button.KINDS +local NO_OPTS = {} + +function Kit.button(x, y, w, h, label, opts) + opts = opts or NO_OPTS + local enabled = opts.enabled ~= false + audit("control", x, y, w, h, label) + local focused = enabled and opts.id + and Kit.focusable(opts.id, x, y, w, h) or false + local hot = enabled and Kit.hover(x, y, w, h) + Button.draw(Kit, x, y, w, h, label, opts, hot, focused) + if not enabled then return false end + return Kit.press(x, y, w, h) + or (not Kit.blockClicks and opts.id ~= nil + and Kit._activateId == opts.id) +end + +function Kit.stepper(x, y, w, h, glyph, opts) + opts = opts or {} + opts.kind = opts.kind or "ghost" + opts.font = opts.font or "small" + return Kit.button(x, y, w, h, glyph, opts) +end + +local CHIP_OPTS = { face = "chip", font = "micro" } + +function Kit.chip(x, y, w, h, label, on, color, id) + CHIP_OPTS.active = on + CHIP_OPTS.color = color + CHIP_OPTS.id = id + return Kit.button(x, y, w, h, label, CHIP_OPTS) +end + +-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE" +-- markers on mod rows. Pass opts.fill for a solid chip (BETA badges); ink +-- then defaults to PAL.inverse so the label stays readable on the fill. +function Kit.tag(x, y, w, h, label, color, opts) + if not G then return end + opts = opts or {} + if opts.fill then + Theme.fillRounded(x, y, w, h, color or PAL.yellow, 1, h / 2) + else + Theme.strokeRounded(x, y, w, h, color or PAL.line, 0.7, 1) + end + local ink = opts.ink or (opts.fill and PAL.inverse) or color or PAL.muted + local ty = y + (h - Kit.textHeight("micro")) / 2 + if opts.bold then + Kit.textCenterBold("micro", label, x, ty, w, ink) + else + Kit.textCenter("micro", label, x, ty, w, ink) + end +end + +local TOAST_ACCEPT = { kind = "primary", font = "small" } +local TOAST_DECLINE = { kind = "ghost", font = "small" } + +function Kit.toastRect(W, text, top, slide) + local s = Kit.scale + local margin = math.floor(16 * s) + local w = math.floor(math.min(360 * s, (W or 0) - 2 * margin)) + local pad = math.floor(12 * s) + local gap = math.floor(8 * s) + local icon = math.floor(22 * s) + local btnH = math.max(Kit.tapMin(), math.floor(30 * s)) + local barH = math.max(2, math.floor(3 * s)) + local textW = w - 2 * pad - icon - gap + local textH = Kit.wrapHeight("small", text or "", textW, 2) + local h = pad + math.max(icon, textH) + gap + btnH + gap + barH + pad + local x = (W or 0) - w - margin + x = x + math.floor((1 - (slide or 1)) * (w + margin)) + return x, top or margin, w, h +end + +function Kit.toast(opts) + opts = opts or NO_OPTS + local s = Kit.scale + local x, y, w, h = Kit.toastRect(opts.W, opts.text, opts.top, opts.slide) + local pad = math.floor(12 * s) + local gap = math.floor(8 * s) + local icon = math.floor(22 * s) + local btnH = math.max(Kit.tapMin(), math.floor(30 * s)) + local barH = math.max(2, math.floor(3 * s)) + local wasOverlay, wasBlock = Kit._overlay, Kit.blockClicks + Kit._overlay = true + Kit.blockClicks = opts.blocked == true + local action = nil + if G then + Kit.card(x, y, w, h, true) + Theme.strokeRounded(x, y, w, h, PAL.lineStrong, Theme.A.focus, 1) + Icons.draw(opts.icon or "mail", x + pad, y + pad, icon, PAL.heading, 1) + end + local tx = x + pad + icon + gap + Kit.textWrapped("small", opts.text or "", tx, y + pad, w - 2 * pad - icon - gap, + PAL.heading, 2) + local by = y + h - pad - barH - gap - btnH + local half = math.floor((w - 2 * pad - gap) / 2) + local id = opts.id or "toast" + TOAST_DECLINE.id = id .. "-decline" + TOAST_ACCEPT.id = id .. "-accept" + if Kit.button(x + pad, by, half, btnH, opts.decline or "Decline", + TOAST_DECLINE) then + action = "decline" + end + if Kit.button(x + pad + half + gap, by, w - 2 * pad - half - gap, btnH, + opts.accept or "Accept", TOAST_ACCEPT) then + action = "accept" + end + if G then + local bw = w - 2 * pad + local left = math.max(0, math.min(1, opts.progress or 1)) + Theme.fillRounded(x + pad, y + h - pad - barH, bw, barH, PAL.line, 0.5, barH / 2) + if left > 0 then + Theme.fillRounded(x + pad, y + h - pad - barH, math.max(barH, bw * left), barH, + left < 0.25 and PAL.red or PAL.green, 1, barH / 2) + end + end + Kit._overlay, Kit.blockClicks = wasOverlay, wasBlock + return action, x, y, w, h +end + +-- Checkbox row. Returns (newChecked, changed). +function Kit.checkbox(x, y, w, h, checked, label, id, labelColor) + local clicked, ink = Kit.row(x, y, w, h, false, id) + local box = 20 * Kit.scale + local bx, by = x + 12 * Kit.scale, y + (h - box) / 2 + if G then + local br = math.min(Theme.radius(), box / 3) + if checked then + Theme.fillRounded(bx, by, box, box, PAL.ink, 1, br) + Kit.textCenter("small", "X", bx, + by + (box - Kit.textHeight("small")) / 2, box, PAL.inverse) + else + Theme.strokeRounded(bx, by, box, box, PAL.line, Theme.A.hover, 1, br) + end + local lx = bx + box + 12 * Kit.scale + Kit.text("mono", Kit.ellipsize("mono", label, x + w - lx - 10 * Kit.scale), + lx, y + (h - Kit.textHeight("mono")) / 2, + labelColor or ink or PAL.text) + end + if clicked then return not checked, true end + return checked, false +end + +-- A two-state switch, for the settings ladders. +function Kit.toggle(x, y, w, h, on, id) + audit("control", x, y, w, h, "toggle") + local focused = id and Kit.focusable(id, x, y, w, h) or false + if G then + -- Track, then a knob inset inside it, so the control reads as a switch + -- rather than as a white square with a word next to it. The label sits + -- in the empty half, which is the half that says what pressing does. + local r = math.min(Theme.radius(), h / 2) + Theme.fillRounded(x, y, w, h, PAL.rowBg, 1, r) + Theme.strokeRounded(x, y, w, h, PAL.line, + (focused or Kit.hover(x, y, w, h)) and Theme.A.focus or Theme.A.hover, 1, r) + local inset = 3 + local knob = w / 2 - inset + Theme.fillRounded(on and (x + w / 2) or (x + inset), y + inset, knob, + h - 2 * inset, PAL.ink, 1, math.min(r, (h - 2 * inset) / 2)) + Kit.textCenter("micro", on and "ON" or "OFF", + on and x or (x + w / 2), y + (h - Kit.textHeight("micro")) / 2, w / 2, + PAL.text) + end + local hitTaken = Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id) + if hitTaken then return not on, true end + return on, false +end + +-- A determinate progress bar with an optional caption. +function Kit.progress(x, y, w, h, frac, label) + Theme.meter(x, y, w, h, (frac or 0) * 100, PAL.ink) + if label then + Kit.text("micro", label, x, y + h + 4 * Kit.scale, PAL.muted) + end +end + +-- --------------------------------------------------------------- text field +function Kit.textfield(id, x, y, w, h, value, placeholder) + audit("control", x, y, w, h, id) + local focusRing = Kit.focusable(id, x, y, w, h) + value = tostring(value or "") + + local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1" + or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1" + or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" + or os.getenv("POKEPORT_SBC") == "1" or os.getenv("ANBERNIC") == "1" + + local clickedOrActivated = (Kit._activateId == id) or (isHandheld and Kit.press(x, y, w, h)) + if isHandheld and clickedOrActivated and not mobile() then + VirtualKeyboard.open({ + text = value, + targetId = id, + title = placeholder or "Enter Text", + onDone = function(newText, confirmed) + if confirmed then + edits[#edits + 1] = "\r" + end + end + }) + end + + if VirtualKeyboard.active and VirtualKeyboard.targetId == id then + value = VirtualKeyboard.text + end + + if Kit.press(x, y, w, h) then Kit._fieldHit = true end + if Kit.press(x, y, w, h) or (Kit._activateId == id) then Kit.focus = id end + local focused = (Kit.focus == id) + if focused then + syncSoftKeyboard(id, x, y, w, h) + for _, e in ipairs(edits) do + if e == "\b" then + value = value:sub(1, -2) + elseif e == "\r" then + Kit.blur() + focused = false + else + value = value .. e + end + end + if focused then Kit._focusDrawn = true end + end + if G then + Theme.fillRounded(x, y, w, h, PAL.bg, 1) + Theme.strokeRounded(x, y, w, h, PAL.line, + (focused or focusRing) and Theme.A.focus or Theme.A.hairline, + focused and 2 or 1) + local pad = 10 * Kit.scale + local ty = y + (h - Kit.textHeight("mono")) / 2 + if value == "" and not focused then + Kit.text("mono", placeholder or "", x + pad, ty, PAL.faint) + else + local shown = Kit.ellipsizeLeft("mono", value, w - 2 * pad) + local tw = Kit.text("mono", shown, x + pad, ty, PAL.heading) + if focused and (Kit.time % 1) < 0.55 then + Theme.fill(x + pad + tw + 2, ty, math.max(1, Kit.scale), + Kit.textHeight("mono"), PAL.ink, 1) + end + end + end + return value +end + +-- -------------------------------------------------------------------- pager +-- Prev / Next / "1-12 of 151". Drawn even for a single page, so a list is +-- never silently truncated. A long list still PAGES rather than scrolling: +-- no momentum, bounded row count per frame. +-- Returns the new page (1-based) and the row height consumed. +local pagerLabels = {} + +function Kit.pager(x, y, w, page, total, perPage, idPrefix, compact) + local h = math.max(Kit.tapMin(), 30 * Kit.scale) + local bw = 74 * Kit.scale + local pages = math.max(1, math.ceil(total / math.max(1, perPage))) + page = math.floor(Theme.clamp(page or 1, 1, pages)) + local gap = 8 * Kit.scale + idPrefix = idPrefix or "pager" + + if Kit.button(x, y, bw, h, "Prev", { kind = "ghost", font = "small", icon = "chevron-left", + enabled = page > 1, id = idPrefix .. ":prev" }) then + page = math.max(1, page - 1) + end + if Kit.button(x + bw + gap, y, bw, h, "Next", { kind = "ghost", icon = "chevron-right", + font = "small", enabled = page < pages, id = idPrefix .. ":next" }) then + page = math.min(pages, page + 1) + end + + local first = total > 0 and ((page - 1) * perPage + 1) or 0 + local last = math.min(total, page * perPage) + -- One memo per pager id. The counts only change when the user pages or the + -- list does; formatting them every frame minted a new string that then + -- missed the width / ellipsis / Text caches by content. + local memo = pagerLabels[idPrefix] + if not memo then memo = {}; pagerLabels[idPrefix] = memo end + if memo.first ~= first or memo.last ~= last or memo.total ~= total + or memo.page ~= page or memo.pages ~= pages then + memo.first, memo.last, memo.total = first, last, total + memo.page, memo.pages = page, pages + memo.label = ("%d-%d of %d (page %d/%d)") + :format(first, last, total, page, pages) + end + local label = compact and (tostring(page) .. " / " .. tostring(pages)) or memo.label + local labelX = x + 2 * bw + 2 * gap + gap + Kit.text("mono", Kit.ellipsize("mono", label, math.max(0, x + w - labelX)), + labelX, y + (h - Kit.textHeight("mono")) / 2, PAL.caption) + return page, h +end + +-- Slice helper so callers never hand-roll page arithmetic (and never draw a +-- row that is off the page -- the entire performance claim rests on this). +function Kit.pageBounds(page, total, perPage) + local pages = math.max(1, math.ceil(total / math.max(1, perPage))) + page = math.floor(Theme.clamp(page or 1, 1, pages)) + local first = (page - 1) * perPage + 1 + local last = math.min(total, page * perPage) + return first, last, page, pages +end + +-- How many rows of `rowH` (plus `gap`) fit in `h` pixels. Panels call this +-- to derive perPage from the real viewport instead of a magic number, so a +-- tall window shows more rows and a phone shows fewer -- with no scrolling +-- either way. +function Kit.rowsThatFit(h, rowH, gap, minRows, maxRows) + local per = math.floor((h + (gap or 0)) / math.max(1, rowH + (gap or 0))) + return math.max(minRows or 1, math.min(maxRows or 99, per)) +end + +Kit.dragX = nil +Kit.dragY = nil +Kit.dragAccum = 0 + +function Kit.dragBegin(x, y) + Kit.dragX, Kit.dragY, Kit.dragAccum = x, y, 0 +end + +function Kit.dragAdd(dy) + if Kit.dragX then Kit.dragAccum = Kit.dragAccum + (dy or 0) end +end + +function Kit.dragEnd() + Kit.dragX, Kit.dragY, Kit.dragAccum = nil, nil, 0 +end + +local function dragOriginIn(x, y, w, h) + if not Kit.dragX then return false end + local x1, y1, x2, y2 = x, y, x + w, y + h + local c = Kit._clipRect + if c then + x1, y1 = math.max(x1, c.x), math.max(y1, c.y) + x2, y2 = math.min(x2, c.x + c.w), math.min(y2, c.y + c.h) + end + return Kit.dragX >= x1 and Kit.dragX <= x2 + and Kit.dragY >= y1 and Kit.dragY <= y2 +end + +-- Mouse wheel over a paginated list turns PAGES. The wheel still has to do +-- something (users expect it), but it moves a bounded page index rather than +-- driving a pixel offset, so there is no scroll state and no interpolation. +function Kit.wheelPage(x, y, w, h, page, total, perPage) + if Kit.blockClicks then return page end + local pages = math.max(1, math.ceil(total / math.max(1, perPage))) + local out = page + if (Kit.wheelY or 0) ~= 0 and Kit.hit(x, y, w, h) then + out = math.floor(Theme.clamp((out or 1) + (Kit.wheelY > 0 and -1 or 1), + 1, pages)) + Kit.wheelY = 0 + end + local acc = Kit.dragAccum or 0 + if acc ~= 0 and dragOriginIn(x, y, w, h) then + local stepPx = math.max(1, math.floor((h or 0) / 2)) + local flips = acc >= 0 and math.floor(acc / stepPx) + or -math.floor(-acc / stepPx) + if flips ~= 0 then + local want = (out or 1) + flips + out = math.floor(Theme.clamp(want, 1, pages)) + Kit.dragAccum = out ~= want and 0 or acc - flips * stepPx + end + end + return out +end + +function Kit.scrollExtent(contentH, viewH) + return math.max(0, (contentH or 0) - math.max(0, viewH or 0)) +end + +function Kit.scrollClamp(offset, maxScroll) + return math.max(0, math.min(offset or 0, math.max(0, maxScroll or 0))) +end + +function Kit.scrollStep(scale) + return math.floor(48 * (scale or Kit.scale)) +end + +function Kit.scrollBarW(scale) + return math.max(2, math.floor(4 * (scale or Kit.scale))) +end + +function Kit.scrollGutter(scale) + return Kit.scrollBarW(scale) + math.max(2, math.floor(4 * (scale or Kit.scale))) +end + +function Kit.scrollHandoff(offset, maxScroll, delta) + local want = (offset or 0) + (delta or 0) + local at = Kit.scrollClamp(want, maxScroll) + return at, want - at +end + +function Kit.scrollWheel(offset, maxScroll, x, y, w, h, step) + local at = Kit.scrollClamp(offset, maxScroll) + local wheel = Kit.wheelY or 0 + if Kit.blockClicks or wheel == 0 or (maxScroll or 0) <= 0 then + return at, false + end + if not Kit.hit(x, y, w, h) then return at, false end + local moved = Kit.scrollClamp(at - wheel * (step or Kit.scrollStep()), + maxScroll) + if moved == at then return at, false end + Kit.wheelY = 0 + return moved, true +end + +function Kit.scrollInput(offset, maxScroll, x, y, w, h) + local at = Kit.scrollWheel(offset, maxScroll, x, y, w, h) + if not Kit.blockClicks and (Kit.dragAccum or 0) ~= 0 + and dragOriginIn(x, y, w, h) then + at = Kit.scrollClamp(at + Kit.dragAccum, maxScroll) + Kit.dragAccum = 0 + end + return at +end + +function Kit.scrollBegin(x, y, w, h, offset, maxScroll) + Kit.pushClip(x, y, math.max(0, w or 0), math.max(0, h or 0)) + return y - Kit.scrollClamp(offset, maxScroll) +end + +function Kit.scrollEnd(x, y, w, h, offset, maxScroll, background) + if (maxScroll or 0) > (offset or 0) + 1 and h > 0 then + local fadeH = math.min(math.floor(24 * Kit.scale), math.floor(h / 4)) + local strength = math.min(1, (maxScroll - (offset or 0)) / math.max(1, fadeH)) + for i = 0, fadeH - 1 do + Theme.fill(x, y + h - fadeH + i, w - Kit.scrollGutter(), 1, + background or PAL.field, strength * ((i + 1) / fadeH) ^ 2) + end + end + Kit.popClip() + if (maxScroll or 0) <= 0 or (h or 0) <= 0 or (w or 0) <= 0 then return end + local barW = Kit.scrollBarW() + local barX = x + w - barW + local at = Kit.scrollClamp(offset, maxScroll) + local thumbH = math.max(math.floor(20 * Kit.scale), + math.floor(h * (h / (h + maxScroll)))) + local thumbY = y + (h - thumbH) * (at / maxScroll) + Theme.fill(barX, y, barW, h, PAL.bg, 0.35) + Theme.fill(barX, thumbY, barW, thumbH, PAL.muted, 0.7) +end + +-- ------------------------------------------------------------------ spinner +-- The one animated element in the UI: a rotating arc of ticks. Drawn as N +-- short lines at descending alpha, which needs no shader, no canvas and no +-- blend-mode change. `t` defaults to the frame clock so every spinner on +-- screen stays in phase. +function Kit.spinner(cx, cy, r, t) + if not G or not has("line") then return end + t = t or Kit.time + local ticks = 12 + local step = (math.pi * 2) / ticks + local head = math.floor((t * 10) % ticks) + if has("setLineWidth") then G.setLineWidth(math.max(2, 2 * Kit.scale)) end + for i = 0, ticks - 1 do + local a = ((ticks - ((head - i) % ticks)) / ticks) + local ang = i * step - math.pi / 2 + local c, s = math.cos(ang), math.sin(ang) + Theme.col(PAL.ink, a * a) + G.line(cx + c * r * 0.55, cy + s * r * 0.55, cx + c * r, cy + s * r) + end + if has("setLineWidth") then G.setLineWidth(1) end +end + +-- ------------------------------------------------------------------- clip +-- Clip drawing to a rect. A stack: pushes intersect with the rect above and +-- a pop restores that rect rather than clearing the scissor, so a nested +-- region can never unclip its parent. The tracked rect also bounds Kit.hit, +-- so a widget clipped out of view is inert instead of taking taps aimed at +-- whatever is drawn where it left. +-- The stack rects are pooled by depth and fully overwritten on every push, +-- so a frame that clips a dozen lists allocates nothing. +local clipStack = {} +local clipPool = {} + +local function applyClip(rect) + Kit._clipRect = rect + if not (G and G.setScissor) then return end + if not rect then + G.setScissor() + elseif rect.w <= 0 or rect.h <= 0 then + -- LOVE rejects negative scissor dimensions; an exhausted clip region is + -- empty, not invalid. + G.setScissor(0, 0, 0, 0) + else + G.setScissor(math.floor(rect.x), math.floor(rect.y), + math.ceil(rect.w), math.ceil(rect.h)) + end +end + +function Kit.pushClip(x, y, w, h) + local prev = clipStack[#clipStack] + local x2, y2 = x + math.max(0, w), y + math.max(0, h) + if prev then + x, y = math.max(x, prev.x), math.max(y, prev.y) + x2 = math.min(x2, prev.x + prev.w) + y2 = math.min(y2, prev.y + prev.h) + end + local n = #clipStack + 1 + local rect = clipPool[n] + if not rect then rect = {}; clipPool[n] = rect end + rect.x, rect.y = x, y + rect.w, rect.h = math.max(0, x2 - x), math.max(0, y2 - y) + clipStack[n] = rect + applyClip(rect) +end + +function Kit.popClip() + clipStack[#clipStack] = nil + applyClip(clipStack[#clipStack]) +end + +-- A pcall-ed draw that raised mid-clip must not leak the stack into later +-- frames (every hit test would stay fenced to the dead rect), so the frame +-- boundary clears it. +function Kit.resetClip() + for i = #clipStack, 1, -1 do clipStack[i] = nil end + applyClip(nil) +end + +return Kit diff --git a/src/ui/kit/Layout.lua b/src/ui/kit/Layout.lua new file mode 100644 index 000000000..2a5e29162 --- /dev/null +++ b/src/ui/kit/Layout.lua @@ -0,0 +1,132 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Shared layout metrics for the launcher and the save editor. +-- +-- Both windows derive one `m` table per frame from the real window size and +-- the platform safe area, and every panel lays itself out in explicit pixels +-- off that table. Explicit pixels are the point: the old view expressed +-- widths as "100%" and leaned on a layout engine to resolve them, which is +-- where the launcher's layout bugs lived (percentages resolving against a +-- border box instead of a content box, auto-sized children measuring zero +-- height inside an auto-sized parent, flex-shrink compressing text until it +-- overlapped). None of those failure modes exist when a column is simply +-- `math.floor((contentW - gap) / 2)`. +-- +-- REFLOW, not shrink: a narrow window drops to fewer columns rather than +-- scaling the desktop layout down. Scale has a floor (Kit.layout clamps to +-- 0.9) so tap targets and text stay legible on a phone. + +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local SafeArea = require("src.core.SafeArea") +local GameViewport = require("src.render.GameViewport") + +local Layout = {} + +-- Breakpoints, in safe-area pixels. Named so panels read intent rather than +-- magic numbers. +Layout.BP = { + twoCol = 640, -- side-by-side columns become possible + threeCol = 1100, -- wide desktop: mod list + detail + chrome +} + +-- Build the frame's metrics. `maxAppW` caps the content column on an +-- ultrawide monitor so the UI stays a readable measure instead of stretching. +-- One metrics table, reused. Every field is a pure function of the window +-- size, the safe area and maxAppW, so the table only has to be rebuilt when +-- one of those changes; the launcher asked for a fresh one 60 times a second +-- and threw all of them away. Callers must treat `m` as read-only (nothing +-- writes to it today) -- a caller that needs a shifted field should save, +-- assign and restore it around the call, not wrap `m` in a proxy. +local M = {} +local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax + +function Layout.metrics(maxAppW) + local W, H = 0, 0 + if love and love.graphics and love.graphics.getDimensions then + W, H = GameViewport.dimensions() + end + local ox, oy, sw, sh = SafeArea.rect() + local s = Kit.layout(sw, sh) + if W == lastW and H == lastH and ox == lastOx and oy == lastOy + and sw == lastSw and sh == lastSh and maxAppW == lastMax then + return M + end + lastW, lastH, lastOx, lastOy = W, H, ox, oy + lastSw, lastSh, lastMax = sw, sh, maxAppW + + local appW = math.min(sw, (maxAppW or 1200) * s) + local m = M + m.W, m.H, m.s = W, H, s + m.x = math.floor(ox + (sw - appW) / 2) + m.top = math.floor(oy) + m.w = math.floor(appW) + m.h = math.floor(sh) + m.pad = math.floor(Theme.clamp(appW * 0.03, 10, 24)) + m.gap = math.floor(12 * s) + m.colGap = math.floor(16 * s) + m.rowH = math.max(Kit.tapMin(), math.floor(44 * s)) + m.btnH = math.max(Kit.tapMin(), math.floor(38 * s)) + m.chip = math.max(Kit.tapMin(), math.floor(40 * s)) + m.railH = math.max(3, math.floor(4 * s)) + m.logoH = math.floor(Theme.clamp(sh * 0.10, 36, 84)) + m.cols = (appW >= Layout.BP.threeCol * s and 3) + or (appW >= Layout.BP.twoCol * s and 2) + or 1 + m.twoCol = m.cols >= 2 + m.contentW = m.w - 2 * m.pad + m.colW = m.twoCol + and math.floor((m.contentW - m.colGap) / 2) + or m.contentW + m.contentX = m.x + m.pad + return m +end + +-- A vertical cursor for stacking blocks down a column. Immediate mode has +-- no layout pass, so panels advance a y by hand; this makes that explicit +-- and keeps the arithmetic in one place. +local Cursor = {} +Cursor.__index = Cursor + +function Layout.cursor(x, y, w) + return setmetatable({ x = x, y = y, w = w, y0 = y }, Cursor) +end + +-- Reserve `h` pixels and return the rect that was reserved. +function Cursor:take(h, gapAfter) + local x, y = self.x, self.y + self.y = self.y + h + (gapAfter or 0) + return x, y, self.w, h +end + +function Cursor:skip(h) + self.y = self.y + h +end + +function Cursor:height() + return self.y - self.y0 +end + +-- Split the cursor's width into `n` equal columns with `gap` between them, +-- returning a function that yields the i-th column's x and width. +function Layout.columns(x, w, n, gap) + n = math.max(1, n) + local cw = math.floor((w - gap * (n - 1)) / n) + return function(i) + return x + (i - 1) * (cw + gap), cw + end +end + +-- Lay a row of buttons out right-aligned within [x, x+w], returning a +-- function that yields each button's x as it is consumed right to left. +function Layout.rightCluster(x, w, gap) + local cursor = x + w + return function(bw) + cursor = cursor - bw + local bx = cursor + cursor = cursor - gap + return bx + end +end + +return Layout diff --git a/src/ui/kit/Loader.lua b/src/ui/kit/Loader.lua new file mode 100644 index 000000000..1b0ed3539 --- /dev/null +++ b/src/ui/kit/Loader.lua @@ -0,0 +1,135 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Non-dismissable loading overlays. +-- +-- The rule this module enforces: ANY operation that can make the UI wait -- +-- a network fetch, a ROM extraction, a mod install, an update check -- puts +-- something obvious on screen for its whole duration. The old launcher +-- failed this twice over: slow work ran synchronously on the main thread, so +-- the window simply stopped responding (the Find Mods tab could hang for +-- minutes with no indication it was doing anything at all), and the few +-- operations that did report progress did so as a small line of text. +-- +-- Two presentations: +-- Loader.overlay(...) a modal scrim + panel, for work the user must wait +-- on before doing anything else. It BLOCKS input +-- (Kit.blockClicks) and offers no dismiss control -- +-- that is deliberate, so a half-finished install can +-- never be clicked around. Cancellable work passes +-- an onCancel and gets exactly one Cancel button. +-- Loader.inline(...) a spinner + label sized to a control, for work that +-- only blocks part of the UI (a row's update check). +-- +-- Callers drive both from a state table; nothing here owns state or time, so +-- the same overlay renders identically in a screenshot test. + +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL + +local Loader = {} + +-- Scrim alpha. Not opaque: the user keeps the context of what they were +-- doing, which is most of why a modal beats a blank screen. +local SCRIM_A = 0.82 + +-- spec = { +-- title = "Fetching mod index", -- required, the verb in progress +-- detail = "index.json from ...", -- optional second line +-- progress = 0..1 or nil, -- nil = indeterminate (spinner) +-- count = "3 of 12", -- optional right-aligned counter +-- onCancel = function() end, -- optional; adds a Cancel button +-- cancelLabel = "Cancel", +-- } +-- Returns true when the cancel button was activated this frame. +function Loader.overlay(m, spec) + if not spec then return false end + local G = love and love.graphics + local W, H = m.W, m.H + + -- The scrim covers the whole window, not just the app column: a modal that + -- leaves the letterboxed margins live is a modal you can click around. + if G then + Theme.fill(0, 0, W, H, PAL.bg, SCRIM_A) + end + -- Everything drawn BEFORE this call is now shielded; the panel below + -- lowers the shield for its own controls. + Kit.blockClicks = true + + local pw = math.floor(math.min(m.w - 2 * m.pad, 460 * m.s)) + local ph = math.floor((spec.onCancel and 210 or 160) * m.s) + local px = math.floor((W - pw) / 2) + local py = math.floor((H - ph) / 2) + + Kit.card(px, py, pw, ph, true) + + local pad = math.floor(18 * m.s) + local cx = px + pw / 2 + + -- Spinner (indeterminate) or a progress bar (determinate). Never both. + local y = py + pad + if spec.progress then + Kit.textCenter("button", spec.title, px + pad, y, pw - 2 * pad, PAL.heading) + y = y + Kit.textHeight("button") + math.floor(14 * m.s) + Kit.progress(px + pad, y, pw - 2 * pad, math.floor(10 * m.s), spec.progress) + y = y + math.floor(10 * m.s) + math.floor(10 * m.s) + local pct = ("%d%%"):format(math.floor(spec.progress * 100 + 0.5)) + Kit.textCenter("small", pct, px + pad, y, pw - 2 * pad, PAL.detail) + y = y + Kit.textHeight("small") + math.floor(6 * m.s) + else + local r = math.floor(16 * m.s) + Kit.spinner(cx, y + r, r) + y = y + 2 * r + math.floor(14 * m.s) + Kit.textCenter("button", spec.title, px + pad, y, pw - 2 * pad, PAL.heading) + y = y + Kit.textHeight("button") + math.floor(6 * m.s) + end + + if spec.detail and spec.detail ~= "" then + Kit.textCenter("small", + Kit.ellipsize("small", spec.detail, pw - 2 * pad), + px + pad, y, pw - 2 * pad, PAL.muted) + y = y + Kit.textHeight("small") + math.floor(4 * m.s) + end + if spec.count and spec.count ~= "" then + Kit.textCenter("micro", spec.count, px + pad, y, pw - 2 * pad, PAL.faint) + end + + local cancelled = false + if spec.onCancel then + -- The one control a blocking overlay may have. It lives inside the + -- panel, so it is the only thing on screen that can take a click. + Kit.blockClicks = false + local bw = math.floor(math.min(pw - 2 * pad, 160 * m.s)) + local bh = m.btnH + if Kit.button(px + (pw - bw) / 2, py + ph - pad - bh, bw, bh, + spec.cancelLabel or "Cancel", + { kind = "ghost", id = "loader:cancel" }) then + cancelled = true + end + Kit.blockClicks = true + end + return cancelled +end + +-- A spinner plus label occupying a control-sized rect. Used in place of the +-- button that started the work, so the row does not reflow while it runs. +function Loader.inline(x, y, w, h, label) + local r = math.floor(math.min(h, 20 * Kit.scale) / 2) + local cx = x + r + 4 + Kit.spinner(cx, y + h / 2, r) + if label then + local lx = cx + r + 8 + Kit.text("small", Kit.ellipsize("small", label, math.max(0, x + w - lx)), + lx, y + (h - Kit.textHeight("small")) / 2, PAL.muted) + end +end + +-- A tiny spinner sized to sit inside a text run (a mod row checking for +-- updates). Returns the width it consumed. +function Loader.dot(x, y, size) + local r = size / 2 + Kit.spinner(x + r, y + r, r) + return size +end + +return Loader diff --git a/src/ui/kit/Theme.lua b/src/ui/kit/Theme.lua new file mode 100644 index 000000000..d70dd1f93 --- /dev/null +++ b/src/ui/kit/Theme.lua @@ -0,0 +1,526 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Cartridge Studio: flat graphite surfaces, quiet borders and bright actions. +-- The version rail is the only animated color treatment. Other depth comes +-- from solid value steps; no canvases, blur passes or per-card meshes. +-- Colors are 0-255 RGB. These primitives also work with the headless stub. + +local Theme = {} + +local PAL = { + -- Solid graphite surfaces; raised controls use a lighter value. + field = { 20, 24, 29 }, -- the page BEHIND the cards + bg = { 20, 24, 29 }, -- light black + surface = { 29, 34, 41 }, -- card interiors + rowBg = { 24, 29, 35 }, -- rows inside a card, one step below it + raised = { 42, 49, 59 }, -- hover feedback + ink = { 240, 244, 249 }, -- the selected/focused fill + -- outlines. Two weights only: a hairline for structure, solid for focus. + line = { 139, 157, 180 }, -- hairline, drawn at alpha 0.35 + lineStrong = { 255, 255, 255 }, -- focus / selection, drawn at alpha 1 + -- text + heading = { 245, 247, 250 }, + text = { 234, 239, 245 }, + detail = { 200, 200, 200 }, + muted = { 161, 172, 187 }, + caption = { 170, 170, 170 }, -- letterspaced section captions + faint = { 110, 110, 110 }, -- slot indices, hints + inverse = { 0, 0, 0 }, -- ink on a white (selected/focused) fill + -- Action colors and status accents. + green = { 85, 232, 135 }, -- safe / confirmed / installed + yellow = { 255, 214, 0 }, -- attention / update available + red = { 255, 80, 90 }, -- destructive + blue = { 76, 163, 240 }, -- links, in-panel navigation + buttonBlue = { 36, 106, 181 }, -- darker fill for white action labels + buttonPurple = { 88, 44, 140 }, + steel = { 120, 120, 120 }, -- disabled + -- the version rail is the one piece of brand colour that stays + railRed = { 255, 60, 72 }, + railBlue = { 70, 150, 255 }, + railGold = { 255, 203, 5 }, -- Yellow cartridge (bright) + railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal) + railSilver = { 190, 198, 210 }, -- Silver cartridge (cool light metal) + railCrystal = { 132, 196, 228 }, -- Crystal cartridge (translucent ice blue) + railLeafGreen = { 38, 162, 78 }, -- LeafGreen cartridge (vibrant deep forest green) + railFireRed = { 220, 48, 48 }, -- FireRed cartridge (deeper red than Red) + railEmerald = { 31, 158, 110 }, + railRuby = { 185, 46, 50 }, + railSapphire = { 53, 94, 196 }, +} +-- Semantic aliases kept so ported call sites read the same as before. +PAL.cardBorder = PAL.line +PAL.greenInk = PAL.inverse +PAL.blueInk = PAL.blue +PAL.redSoft = PAL.red +PAL.greenDark = PAL.green +Theme.PAL = PAL + +-- Standard alphas, so "hairline" means one thing everywhere. +Theme.A = { + hairline = 0.35, + hover = 0.65, + focus = 1.0, + fillHover= 1.0, + disabled = 0.30, +} + +Theme.BUTTON = { + radius = 8, + ringPad = 2, + ringWidth = 2, + labelInset = 16, + labelPad = 10, + iconPad = 0.24, + letterGap = 4, + disabledA = 0.45, + embossHot = 0.35, + embossRest = 0.25, + embossDisabled = 0.4, + glowHz = 3, + glowBase = 0.25, + glowAmp = 0.75, + iconRestA = 0.85, +} + +Theme.CARD = { + radius = 9, + shadow = false, + fill = PAL.surface, + fillA = 1, + stroke = PAL.line, + strokeA = Theme.A.hairline, +} + +Theme.CARD_VARIANT = { + emphasis = { strokeA = Theme.A.focus }, + muted = { + fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = 0.25, shadow = false, + }, + mutedHot = { + fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = Theme.A.hover, shadow = false, + }, + empty = { fillA = 0, strokeA = 0.22, shadow = false, radius = "ctl" }, + warn = { + fill = PAL.rowBg, stroke = PAL.yellow, strokeA = Theme.A.hover, + shadow = false, radius = "ctl", + }, + row = { + fill = PAL.rowBg, stroke = PAL.line, strokeA = Theme.A.hairline, + shadow = false, radius = "ctl", + }, + rowHover = { + fill = PAL.raised, stroke = PAL.line, strokeA = Theme.A.hover, + shadow = false, radius = "ctl", + }, + rowSelected = { + fill = PAL.ink, strokeA = 0, shadow = false, radius = "ctl", + }, + hairline = { + fillA = 0, stroke = PAL.line, strokeA = Theme.A.hairline, + shadow = false, radius = "ctl", + }, + badge = { + fill = PAL.bg, stroke = PAL.yellow, strokeA = Theme.A.hover, + shadow = false, radius = 0, + }, +} + +local G = love and love.graphics or nil + +local has = {} +local function probe(name) + if has[name] == nil then has[name] = (G and type(G[name]) == "function") or false end + return has[name] +end +Theme.probe = probe + +function Theme.col(c, a) + if not G then return end + G.setColor(c[1] / 255, c[2] / 255, c[3] / 255, a or 1) +end +local col = Theme.col + +function Theme.clamp(n, lo, hi) + if n < lo then return lo end + if n > hi then return hi end + return n +end +local clamp = Theme.clamp + +-- --------------------------------------------------------------- primitives +-- Square, flat, snapped to whole pixels. Snapping matters at 1px line width: +-- a rect on a half pixel renders as a 2px grey smear instead of a crisp white +-- hairline, which is the whole look. +local function snap(v) return math.floor(v + 0.5) end +Theme.snap = snap + +function Theme.fill(x, y, w, h, c, a) + if not G or w <= 0 or h <= 0 then return end + col(c or PAL.bg, a or 1) + G.rectangle("fill", snap(x), snap(y), snap(w), snap(h)) +end + +-- Corner radius for controls. Fixed rather than scaled: LOVE tessellates a +-- rounded rect by radius, so a scale-driven radius would change the vertex +-- count with the window size, and these are the two tiers the design needs. +-- Controls get the smaller one, containers the larger, so a button never +-- looks like a card and a card never looks like a button. +function Theme.radius() + return Theme.BUTTON.radius +end + +function Theme.cardRadius() + return Theme.CARD.radius +end + +-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider +-- and one step lower than the last -- a cheap falloff that needs no blur, no +-- canvas and no blend-mode change, so it stays inside the pipeline budget the +-- rest of this file is written to. Drawn BEFORE the surface it belongs to, +-- and never for a control (only containers cast one, or the whole screen +-- reads as floating debris). +function Theme.shadow(x, y, w, h, r) + if not G or w <= 0 or h <= 0 then return end + r = r or Theme.cardRadius() + for i = 1, 3 do + local spread = i * 2 + col(PAL.bg, 0.13) + G.rectangle("fill", snap(x - spread), snap(y - spread + i * 3), + snap(w + 2 * spread), snap(h + 2 * spread), r + spread, r + spread) + end +end + +function Theme.fillRounded(x, y, w, h, c, a, r, segments) + if not G or w <= 0 or h <= 0 then return end + r = r or Theme.radius() + col(c or PAL.bg, a or 1) + x, y, w, h = snap(x), snap(y), snap(w), snap(h) + if segments and r > 1 and w > 2 and h > 2 and (a or 1) == 1 and probe("setLineWidth") then + -- Filled polygons have no edge antialiasing on a non-MSAA canvas. + -- Inset the opaque body, then finish its boundary with a smooth 1px + -- line in the same colour. Translucent overlays keep a single fill so + -- their opacity does not accumulate at the edge. + local innerRadius = math.min(r, w / 2, h / 2) - 0.5 + local oldWidth = probe("getLineWidth") and G.getLineWidth() or 1 + local oldStyle = probe("getLineStyle") and G.getLineStyle() or nil + G.rectangle("fill", x + 0.5, y + 0.5, w - 1, h - 1, innerRadius, innerRadius, segments) + G.setLineWidth(1) + if oldStyle and probe("setLineStyle") then G.setLineStyle("smooth") end + G.rectangle("line", x + 0.5, y + 0.5, w - 1, h - 1, innerRadius, innerRadius, segments) + G.setLineWidth(oldWidth) + if oldStyle and probe("setLineStyle") then G.setLineStyle(oldStyle) end + else + G.rectangle("fill", x, y, w, h, r, r, segments) + end +end + +function Theme.strokeRounded(x, y, w, h, c, a, lw, r, segments) + if not G or w <= 0 or h <= 0 then return end + lw = lw or 1 + r = r or Theme.radius() + if probe("setLineWidth") then G.setLineWidth(lw) end + col(c or PAL.line, a or Theme.A.hairline) + -- Explicitly tessellated controls keep the outline's outer arc aligned + -- with the fill, including thicker selection rings. + local insetRadius = segments and math.max(0, r - lw / 2) or r + G.rectangle("line", snap(x) + lw / 2, snap(y) + lw / 2, + snap(w) - lw, snap(h) - lw, insetRadius, insetRadius, segments) + if probe("setLineWidth") then G.setLineWidth(1) end +end + +-- EMBOSS. A lit top edge and a shaded bottom edge inside the control, which +-- is what makes a flat fill read as a raised key. Two thin rects on top of +-- the fill -- no gradient mesh, no stencil, no blend-mode change, so it costs +-- the same pipeline state as everything around it. +function Theme.emboss(x, y, w, h, strength) + if not G or w <= 2 or h <= 2 then return end + strength = strength or 1 + local t = math.max(1, math.floor(h * 0.10)) + -- The inset must clear the corner arc, but a narrow control (a stepper, a + -- row chip) is thinner than two radii -- clamp or the highlight rect goes + -- negative-width and vanishes. + local r = math.min(Theme.radius(), math.floor(w / 3)) + -- highlight along the top + col(PAL.ink, 0.28 * strength) + G.rectangle("fill", snap(x) + r, snap(y) + 1, snap(w) - 2 * r, t) + -- shadow along the bottom + col(PAL.bg, 0.30 * strength) + G.rectangle("fill", snap(x) + r, snap(y + h) - t - 1, snap(w) - 2 * r, t) +end + +-- Faux bold: the UI face ships in one weight, so a bold run is the same text +-- drawn a second time one pixel across. Callers do this only for button +-- labels, where the extra draw is bounded by the number of controls on +-- screen and the text is already a cached Text object. +Theme.BOLD_OFFSET = 1 + +-- A 1px outline drawn INSIDE the rect, so a bordered control never bleeds +-- into its neighbour's pixel and adjacent outlines never double up to 2px. +function Theme.stroke(x, y, w, h, c, a, lw) + if not G or w <= 0 or h <= 0 then return end + lw = lw or 1 + if probe("setLineWidth") then G.setLineWidth(lw) end + col(c or PAL.line, a or Theme.A.hairline) + G.rectangle("line", snap(x) + lw / 2, snap(y) + lw / 2, + snap(w) - lw, snap(h) - lw) + if probe("setLineWidth") then G.setLineWidth(1) end +end + +-- The design's only container: a rounded surface a few values above the +-- field, its own drop shadow, and a white hairline. `emphasis` raises the +-- outline to full white (used for the focused/active card). +function Theme.card(x, y, w, h, variant) + local spec = Theme.CARD + local v + if variant == true then + v = Theme.CARD_VARIANT.emphasis + elseif type(variant) == "string" then + v = Theme.CARD_VARIANT[variant] + elseif type(variant) == "table" then + v = variant + end + local radius = (v and v.radius) or spec.radius + if radius == "ctl" then radius = Theme.BUTTON.radius end + local fill = (v and v.fill) or spec.fill + local fillA = spec.fillA + if v and v.fillA ~= nil then fillA = v.fillA end + local stroke = (v and v.stroke) or spec.stroke + local strokeA = spec.strokeA + if v and v.strokeA ~= nil then strokeA = v.strokeA end + local shadow = spec.shadow + if v and v.shadow ~= nil then shadow = v.shadow end + local strokeW = (v and v.strokeW) or 1 + if fillA > 0 and fill then + if shadow then Theme.shadow(x, y, w, h, radius) end + Theme.fillRounded(x, y, w, h, fill, fillA, radius) + end + if strokeA > 0 and stroke then + Theme.strokeRounded(x, y, w, h, stroke, strokeA, strokeW, radius) + end +end + +function Theme.row(x, y, w, h, state) + if state == "selected" then + Theme.card(x, y, w, h, "rowSelected") + return PAL.inverse + end + Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row") + return PAL.text +end + +-- A percentage meter (HP, box fill, dex completion, import progress). +-- pct is 0-100. Outline + solid fill, rounded to the track's own half-height +-- so a thin bar reads as a capsule instead of a clipped rectangle. +function Theme.meter(x, y, w, h, pct, c) + if not G then return end + local r = math.min(Theme.radius(), h / 2) + Theme.strokeRounded(x, y, w, h, PAL.line, Theme.A.hairline, 1, r) + local fill = (w - 2) * clamp((pct or 0) / 100, 0, 1) + if fill > 0 then + Theme.fillRounded(x + 1, y + 1, fill, h - 2, c or PAL.ink, 1, + math.min(r, fill / 2)) + end +end + +local railColors = { + PAL.railRed, PAL.railBlue, PAL.railGold, PAL.railAmber, PAL.railSilver, + PAL.railCrystal, PAL.railFireRed, PAL.railLeafGreen, PAL.railEmerald, + PAL.railRuby, PAL.railSapphire, +} + +-- One seamless sweep every 24 seconds. Pixel strips keep this in the same +-- batched rectangle pipeline as the rest of the theme, without a shader. +function Theme.versionRail(x, y, w, h, colors) + if not G then return end + local railColors = (colors and #colors > 0) and colors or railColors + x, y, w, h = snap(x), snap(y), snap(w), snap(h) + if w <= 0 or h <= 0 then return end + local now = love.timer and love.timer.getTime and love.timer.getTime() or 0 + local phase = (now % 24) / 24 + for px = 0, w - 1 do + local pos = ((px / w - phase) % 1) * #railColors + local index = math.floor(pos) + local a = railColors[index + 1] + local b = railColors[(index + 1) % #railColors + 1] + local t = pos - index + G.setColor((a[1] + (b[1] - a[1]) * t) / 255, + (a[2] + (b[2] - a[2]) * t) / 255, + (a[3] + (b[3] - a[3]) * t) / 255, 1) + G.rectangle("fill", x + px, y, 1, h) + end +end + +-- ------------------------------------------------------------------- text +-- Letterspaced caption text. The UI font has no tracking control, so this +-- advances glyph by glyph; captions are short by construction. +-- Measuring never throws. Third-party strings (mod names from an index, +-- translated captions) reach these primitives unvalidated. +local function safeWidthOrZero(font, s) + local ok, w = pcall(font.getWidth, font, s) + return ok and w or 0 +end + +-- Steps CODEPOINTS, not bytes: a translated caption (the JP strings) is +-- multi-byte, and printing half a sequence is a "UTF-8 decoding error" that +-- takes the frame down. +local function eachChar(text, fn) + local i = 1 + local n = #text + while i <= n do + local j = i + 1 + while j <= n do + local b = text:byte(j) + if b < 0x80 or b >= 0xC0 then break end + j = j + 1 + end + fn(text:sub(i, j - 1)) + i = j + end +end + +function Theme.spaced(font, text, x, y, spacing) + if not G or not font then return 0 end + local cx = x + eachChar(tostring(text), function(ch) + pcall(G.print, ch, cx, y) + cx = cx + safeWidthOrZero(font, ch) + spacing + end) + return math.max(0, cx - x - spacing) +end + +function Theme.spacedWidth(font, text, spacing) + if not font then return 0 end + local w = 0 + eachChar(tostring(text), function(ch) + w = w + safeWidthOrZero(font, ch) + spacing + end) + return math.max(0, w - spacing) +end + +-- UTF-8 stepping. Truncation MUST move whole codepoints: LOVE's Font:getWidth +-- raises "UTF-8 decoding error" on a string cut through a multi-byte sequence, +-- and a launcher listing mods with non-ASCII names (the JP index) hits that on +-- the first frame. A continuation byte is 10xxxxxx (0x80..0xBF). +local function prevCharStart(s, i) + -- largest j < i where s:byte(j) starts a codepoint + local j = i - 1 + while j > 1 do + local b = s:byte(j) + if b < 0x80 or b >= 0xC0 then break end + j = j - 1 + end + return j +end + +local function nextCharStart(s, i) + local j = i + 1 + while j <= #s do + local b = s:byte(j) + if b < 0x80 or b >= 0xC0 then break end + j = j + 1 + end + return j +end + +-- Width that never throws on malformed input: a mod name can carry anything. +local function safeWidth(font, s) + local ok, w = pcall(font.getWidth, font, s) + return ok and w or math.huge +end +Theme.safeWidth = safeWidth + +-- Clip text to a pixel width with a trailing ellipsis. Results are memoised +-- per (font, text, width) in Kit's measurement cache -- this function is the +-- single hottest string operation in a list-heavy frame, and it is O(n) in +-- glyphs with a getWidth call per step. +function Theme.ellipsize(font, text, maxW) + text = tostring(text or "") + if not font then return text end + -- A non-positive budget means "nothing fits", not "everything fits". + if maxW <= 0 then return "" end + if safeWidth(font, text) <= maxW then return text end + local ell = "..." + local ew = safeWidth(font, ell) + local last = #text + 1 -- one past the end of the kept prefix + while last > 1 do + last = prevCharStart(text, last) + local head = text:sub(1, last - 1) + if safeWidth(font, head) + ew <= maxW then return head .. ell end + end + return ell +end + +-- Save paths truncate from the LEFT so the filename survives. +function Theme.ellipsizeLeft(font, text, maxW) + text = tostring(text or "") + if not font then return text end + if maxW <= 0 then return "" end + if safeWidth(font, text) <= maxW then return text end + local ell = "..." + local ew = safeWidth(font, ell) + local i = 1 + while i <= #text do + i = nextCharStart(text, i) + local tail = text:sub(i) + if safeWidth(font, tail) + ew <= maxW then return ell .. tail end + end + return ell +end + +-- The background: one flat clear to the faintly red-cast field colour. One +-- call, no mesh, no fan, no allocation -- the old radial field built a +-- 66-vertex mesh EVERY frame. The tint is deliberately small (a handful of +-- points of red at near-black): enough that the cards read as sitting ON +-- something, not enough to compete with the tri-colour rail for brand duty. +function Theme.field() + if not G then return end + G.clear(PAL.field[1] / 255, PAL.field[2] / 255, PAL.field[3] / 255, 1) +end + +-- ------------------------------------------------------------------- fonts +-- Font set, rebuilt only when the scale changes. Sizes are integers by +-- construction: fractional sizes measure and render at different widths, +-- which is what made ported launcher text overrun its measured box. +function Theme.fonts(s) + if not probe("newFont") then return {} end + -- Every face goes through UiFont.attach, which hangs a kana/CJK fallback + -- off it. Without that a translated build renders the entire launcher as + -- tofu boxes -- LOVE's default face is Latin-only. + local UiFont + local okUi, mod = pcall(require, "src.render.UiFont") + if okUi then UiFont = mod end + local cache = {} + local function f(px) + local n = math.max(8, math.floor(px + 0.5)) + if not cache[n] then + local face = G.newFont(n) + if UiFont and UiFont.attach then + local ok, attached = pcall(UiFont.attach, face, n) + if ok and attached then face = attached end + end + cache[n] = face + end + return cache[n] + end + return { + scale = s, + wordmark = f(14 * s), + brand = f(11 * s), + chip = f(11 * s), + tile = f(13 * s), + tab = f(13 * s), + button = f(14 * s), + small = f(12 * s), + tiny = f(11 * s), + micro = f(10 * s), + caption = f(12 * s), + mono = f(12 * s), + monoRow = f(13 * s), + monoBig = f(18 * s), + title = f(24 * s), + headline = f(26 * s), + stat = f(19 * s), + } +end + +return Theme diff --git a/src/ui/kit/Toast.lua b/src/ui/kit/Toast.lua new file mode 100644 index 000000000..49b45f882 --- /dev/null +++ b/src/ui/kit/Toast.lua @@ -0,0 +1,153 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +local Toast = {} + +Toast.KINDS = { + ok = { color = "green", icon = "check", hold = 3.5 }, + error = { color = "red", icon = "triangle-alert", hold = 7 }, + warn = { color = "yellow", icon = "triangle-alert", hold = 6 }, + info = { color = "blue", icon = "circle-help", hold = 5 }, +} +Toast.SLIDE = .18 +Toast.FADE = .25 + +local function now() + if love and love.timer and love.timer.getTime then return love.timer.getTime() end + return nil +end + +local function holdOf(t) + if t.sticky then return math.huge end + return t.hold or Toast.KINDS[t.kind].hold +end + +local function expired(t, at) + return t.at and at and (at - t.at) > holdOf(t) + Toast.FADE +end + +function Toast.show(store, text, kind, opts) + if text == nil or text == "" then store.toast = nil; return end + text = tostring(text) + kind = Toast.KINDS[kind] and kind or "info" + opts = opts or {} + local t, at = store.toast, now() + if t and t.text == text and t.kind == kind and not expired(t, at) then + if t.at and at then t.at = at - math.min(Toast.SLIDE, at - t.at) end + t.sticky, t.hold = opts.sticky, opts.hold + return t + end + store.toast = { text = text, kind = kind, at = at, sticky = opts.sticky, hold = opts.hold } + return store.toast +end + +function Toast.clear(store) + store.toast = nil +end + +function Toast.current(store) + local t = store and store.toast + if not t then return nil end + if expired(t, now()) then store.toast = nil; return nil end + return t +end + +function Toast.hit(store, x, y) + local t = Toast.current(store) + local r = t and t.rect + if r and t.at then + local at = now() + local elapsed = at and (at - t.at) + if elapsed and (elapsed < Toast.SLIDE or elapsed > holdOf(t)) then return false end + end + return r ~= nil and x ~= nil and y ~= nil + and x >= r[1] and x <= r[1] + r[3] and y >= r[2] and y <= r[2] + r[4] +end + +local function wrapLines(font, text, w) + if not font then return { text } end + local ok, _, lines = pcall(font.getWrap, font, text, w) + return (ok and lines and #lines > 0) and lines or { text } +end + +local function lineHeight(font) + return font and font:getHeight() or 12 +end + +local function cachedLines(t, font, w) + local c = t._wrap + if c and c.font == font and c.w == w then return c.lines end + local lines = wrapLines(font, t.text, w) + t._wrap = { font = font, w = w, lines = lines } + return lines +end + +local function defaultText(font, lines, x, y, w, color, maxLines) + local G = love and love.graphics + if not G or not font then return end + local Theme = require("src.ui.kit.Theme") + local lh = lineHeight(font) + G.setFont(font) + Theme.col(color, 1) + for i = 1, math.min(#lines, maxLines) do + local line = lines[i] + if i == maxLines and #lines > maxLines then line = Theme.ellipsize(font, line .. "...", w) end + pcall(G.print, line, Theme.snap(x), Theme.snap(y + (i - 1) * lh)) + end +end + +function Toast.draw(store, area) + local t = store and store.toast + if not t then return nil end + local at = now() + if not t.at then t.at = at end + local elapsed = (at and t.at) and (at - t.at) or 0 + local hold = holdOf(t) + if elapsed > hold + Toast.FADE then store.toast = nil; return nil end + local Theme = require("src.ui.kit.Theme") + local Icons = require("src.ui.kit.Icons") + local PAL = Theme.PAL + local style = Toast.KINDS[t.kind] + local slide = math.min(1, elapsed / Toast.SLIDE, (hold + Toast.FADE - elapsed) / Toast.FADE) + slide = 1 - (1 - math.max(0, slide)) ^ 3 + local s, color = area.s or 1, PAL[style.color] + local pad, gap, icon = 12 * s, 10 * s, 20 * s + local maxLines = area.maxLines or 4 + local w = area.width or math.min(420 * s, area.w - 32 * s) + local textW = w - 2 * pad - icon - gap + local lines = (not area.wrapHeight or not area.drawText) and cachedLines(t, area.font, textW) + local textH = area.wrapHeight and area.wrapHeight(t.text, textW, maxLines) + or math.min(#lines, maxLines) * lineHeight(area.font) + local h = 2 * pad + math.max(icon, textH) + local x = (area.x or 0) + (area.w - w) / 2 + local y, fromAbove + if area.place then y, fromAbove = area.place(x, w, h) end + if y == nil then + if area.centerY then + y, fromAbove = area.centerY - h / 2, true + elseif area.top and not (area.bottom and area.maxY and area.top + 12 * s + h > area.maxY) then + y, fromAbove = area.top + 12 * s, true + else + y, fromAbove = area.bottom - h - 12 * s, false + end + end + if fromAbove then + y = y - (1 - slide) * (h + 24 * s) + else + y = y + (1 - slide) * (h + 24 * s) + end + t.rect = { x, y, w, h } + local r = area.radius or Theme.cardRadius() + Theme.fillRounded(x + 2 * s, y + 4 * s, w, h, PAL.inverse, .35, r) + Theme.fillRounded(x, y, w, h, PAL.surface, .98, r) + Theme.fillRounded(x, y, w, h, color, .16, r) + Theme.strokeRounded(x, y, w, h, color, .75, 1.5, r) + Icons.draw(style.icon, x + pad, y + (h - icon) / 2, icon, color, 1) + if area.drawText then + area.drawText(t.text, x + pad + icon + gap, y + pad, textW, PAL.heading, maxLines) + else + defaultText(area.font, lines, x + pad + icon + gap, y + pad, textW, PAL.heading, maxLines) + end + return t.rect +end + +return Toast diff --git a/src/ui/kit/Transition.lua b/src/ui/kit/Transition.lua new file mode 100644 index 000000000..c88c490c4 --- /dev/null +++ b/src/ui/kit/Transition.lua @@ -0,0 +1,134 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +local Transition = {} + +Transition.reduceMotion = false +Transition.armed = false + +Transition.DURATIONS = { + ["in"] = 0.12, + out = 0.09, + tab = 0.18, + push = 0.16, + pop = 0.16, +} + +Transition.LAYERS = { "tabs", "online", "modal" } +Transition.LOCAL = { "box" } +Transition.PASSIVE = { "toast" } + +local ALL = {} +for i = 1, #Transition.LAYERS do ALL[#ALL + 1] = Transition.LAYERS[i] end +for i = 1, #Transition.LOCAL do ALL[#ALL + 1] = Transition.LOCAL[i] end +for i = 1, #Transition.PASSIVE do ALL[#ALL + 1] = Transition.PASSIVE[i] end + +local layers = {} +for i = 1, #ALL do + layers[ALL[i]] = { + active = false, kind = nil, dir = 1, + t0 = 0, dur = 0, p = 1, + from = nil, fromAt = nil, to = nil, + } +end + +local function now() + if love and love.timer and love.timer.getTime then + return love.timer.getTime() + end + return 0 +end +Transition.now = now + +local function ease(t) + if t <= 0 then return 0 end + if t >= 1 then return 1 end + local u = 1 - t + return 1 - u * u * u +end +Transition.ease = ease + +function Transition.update(t) + t = t or now() + for i = 1, #ALL do + local L = layers[ALL[i]] + if L.active then + local raw = 1 + if L.dur > 0 then raw = (t - L.t0) / L.dur end + if raw >= 1 then + L.active, L.p = false, 1 + elseif raw <= 0 then + L.p = 0 + else + L.p = ease(raw) + end + end + end +end + +function Transition.start(layer, kind, opts) + local L = layers[layer] + if not L then return false end + L.kind = kind + L.dir = (opts and opts.dir) or 1 + L.from = opts and opts.from or nil + L.fromAt = opts and opts.fromAt or nil + L.to = opts and opts.to or nil + local dur = (opts and opts.duration) or Transition.DURATIONS[kind] or 0.15 + if Transition.reduceMotion or not Transition.armed or dur <= 0 then + L.active, L.p, L.dur, L.t0 = false, 1, 0, 0 + return false + end + L.active, L.p, L.dur, L.t0 = true, 0, dur, now() + return true +end + +function Transition.get(layer) + local L = layers[layer] + if L and L.active then return L end + return nil +end + +function Transition.progress(layer) + local L = layers[layer] + if not L or not L.active then return 1 end + return L.p +end + +function Transition.kind(layer) + local L = layers[layer] + if L and L.active then return L.kind end + return nil +end + +function Transition.dir(layer) + local L = layers[layer] + if L and L.active then return L.dir end + return 0 +end + +function Transition.active(layer) + if layer then + local L = layers[layer] + return (L and L.active) == true + end + for i = 1, #Transition.LAYERS do + if layers[Transition.LAYERS[i]].active then return true end + end + return false +end + +function Transition.clear(layer) + local L = layers[layer] + if not L then return false end + L.active, L.p, L.dur, L.t0 = false, 1, 0, 0 + L.kind, L.from, L.fromAt, L.to = nil, nil, nil, nil + return true +end + +function Transition.reset() + for i = 1, #ALL do + Transition.clear(ALL[i]) + end +end + +return Transition diff --git a/src/ui/kit/VirtualKeyboard.lua b/src/ui/kit/VirtualKeyboard.lua new file mode 100644 index 000000000..cdf8dab4d --- /dev/null +++ b/src/ui/kit/VirtualKeyboard.lua @@ -0,0 +1,310 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Controller-driven on-screen keyboard (OSK) for handhelds and gamepads. +-- Allows typing URLs (mod index, repositories), mod search queries, and custom names +-- without needing a physical keyboard. + +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL +local Kit = nil +local function getKit() + if not Kit then Kit = require("src.ui.kit.Kit") end + return Kit +end + +local VirtualKeyboard = { + active = false, + text = "", + title = "Enter Text", + targetId = nil, + onDone = nil, + row = 1, + col = 1, + mode = 1, -- 1: lower, 2: upper, 3: symbols +} + +local ROWS_LOWER = { + { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=" }, + { "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "/", ":" }, + { "a", "s", "d", "f", "g", "h", "j", "k", "l", ".", "_", "@" }, + { "z", "x", "c", "v", "b", "n", "m", "?", "!", "&", "%", "#" }, + { { id = "shift", label = "Shift", w = 2 }, { id = "sym", label = "Sym", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } }, + { { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } }, +} + +local ROWS_UPPER = { + { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=" }, + { "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "/", ":" }, + { "A", "S", "D", "F", "G", "H", "J", "K", "L", ".", "_", "@" }, + { "Z", "X", "C", "V", "B", "N", "M", "?", "!", "&", "%", "#" }, + { { id = "shift", label = "shift", w = 2 }, { id = "sym", label = "Sym", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } }, + { { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } }, +} + +local ROWS_SYM = { + { "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "_", "+" }, + { "~", "`", "{", "}", "[", "]", "|", "\\", ";", ":", "'", "\"" }, + { "<", ">", ",", ".", "/", "?", "=", "-", "*", "/", "\\", "~" }, + { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", ".", "=" }, + { { id = "shift", label = "ABC", w = 2 }, { id = "sym", label = "123", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } }, + { { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } }, +} + +local ROWS_DIGITS = { + { "1", "2", "3" }, + { "4", "5", "6" }, + { "7", "8", "9" }, + { { id = "back", label = "Bksp" }, "0", { id = "done", label = "Done" } }, +} + +VirtualKeyboard.ROWS_DIGITS = ROWS_DIGITS + +local function isLinuxHandheld() + return os.getenv("HANDHELD") == "1" + or os.getenv("PORTMASTER") == "1" + or os.getenv("POKEPORT_HANDHELD") == "1" + or os.getenv("TRIMUI") == "1" + or os.getenv("MUOS") == "1" + or os.getenv("KNULLI") == "1" + or os.getenv("POKEPORT_SBC") == "1" + or os.getenv("ANBERNIC") == "1" +end + +function VirtualKeyboard.isSupported() + return isLinuxHandheld() +end + +function VirtualKeyboard.open(opts) + if not isLinuxHandheld() then return false end + opts = opts or {} + VirtualKeyboard.active = true + VirtualKeyboard.text = tostring(opts.text or "") + VirtualKeyboard.title = opts.title or "Enter Text" + VirtualKeyboard.targetId = opts.targetId + VirtualKeyboard.onDone = opts.onDone + VirtualKeyboard.row = 1 + VirtualKeyboard.col = 1 + VirtualKeyboard.mode = opts.digits and 4 or 1 + VirtualKeyboard.maxLen = tonumber(opts.maxLen) + return true +end + +function VirtualKeyboard.close(confirmed) + if not VirtualKeyboard.active then return end + local txt = VirtualKeyboard.text + local cb = VirtualKeyboard.onDone + VirtualKeyboard.active = false + VirtualKeyboard.targetId = nil + VirtualKeyboard.onDone = nil + if cb then + cb(txt, confirmed == true) + end +end + +local function currentRows() + if VirtualKeyboard.mode == 4 then return ROWS_DIGITS end + if VirtualKeyboard.mode == 2 then return ROWS_UPPER end + if VirtualKeyboard.mode == 3 then return ROWS_SYM end + return ROWS_LOWER +end + +VirtualKeyboard.currentRows = currentRows + +local function triggerKey(key) + if type(key) == "string" then + local cap = VirtualKeyboard.maxLen + if cap and #VirtualKeyboard.text >= cap then return end + VirtualKeyboard.text = VirtualKeyboard.text .. key + elseif type(key) == "table" then + if key.id == "shift" then + VirtualKeyboard.mode = (VirtualKeyboard.mode == 2) and 1 or 2 + elseif key.id == "sym" then + VirtualKeyboard.mode = (VirtualKeyboard.mode == 3) and 1 or 3 + elseif key.id == "space" then + VirtualKeyboard.text = VirtualKeyboard.text .. " " + elseif key.id == "back" then + VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2) + elseif key.id == "clear" then + VirtualKeyboard.text = "" + elseif key.id == "done" then + VirtualKeyboard.close(true) + elseif key.id == "url_https" then + VirtualKeyboard.text = VirtualKeyboard.text .. "https://" + elseif key.id == "url_http" then + VirtualKeyboard.text = VirtualKeyboard.text .. "http://" + elseif key.id == "url_json" then + VirtualKeyboard.text = VirtualKeyboard.text .. ".json" + elseif key.id == "url_zip" then + VirtualKeyboard.text = VirtualKeyboard.text .. ".zip" + end + end +end + +function VirtualKeyboard.gamepadpressed(button) + if not VirtualKeyboard.active then return false end + + local rows = currentRows() + local r = VirtualKeyboard.row + local c = VirtualKeyboard.col + + if button == "dpup" then + VirtualKeyboard.row = math.max(1, r - 1) + local maxC = #rows[VirtualKeyboard.row] + if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end + return true + elseif button == "dpdown" then + VirtualKeyboard.row = math.min(#rows, r + 1) + local maxC = #rows[VirtualKeyboard.row] + if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end + return true + elseif button == "dpleft" then + VirtualKeyboard.col = math.max(1, c - 1) + return true + elseif button == "dpright" then + VirtualKeyboard.col = math.min(#rows[r], c + 1) + return true + elseif button == "a" then + local key = rows[r] and rows[r][c] + if key then triggerKey(key) end + return true + elseif button == "b" then + VirtualKeyboard.close(false) + return true + elseif button == "x" then + VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2) + return true + elseif button == "y" then + if VirtualKeyboard.mode == 4 then return true end + VirtualKeyboard.text = VirtualKeyboard.text .. " " + return true + elseif button == "leftshoulder" or button == "rightshoulder" then + if VirtualKeyboard.mode == 4 then return true end + VirtualKeyboard.mode = (VirtualKeyboard.mode % 3) + 1 + local maxC = #currentRows()[VirtualKeyboard.row] + if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end + return true + elseif button == "start" then + VirtualKeyboard.close(true) + return true + end + + return true -- consume all input while keyboard modal is up +end + +function VirtualKeyboard.keypressed(key) + if not VirtualKeyboard.active then return false end + if key == "escape" then + VirtualKeyboard.close(false) + return true + elseif key == "return" then + VirtualKeyboard.close(true) + return true + elseif key == "backspace" then + VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2) + return true + end + return false +end + +function VirtualKeyboard.textinput(text) + if not VirtualKeyboard.active then return false end + if VirtualKeyboard.mode == 4 then text = tostring(text or ""):gsub("%D", "") end + local cap = VirtualKeyboard.maxLen + VirtualKeyboard.text = VirtualKeyboard.text .. text + if cap and #VirtualKeyboard.text > cap then + VirtualKeyboard.text = VirtualKeyboard.text:sub(1, cap) + end + return true +end + +function VirtualKeyboard.draw(m) + if not VirtualKeyboard.active then return end + + local Kit = getKit() + local s = m and m.s or Kit.scale or 1 + local W = (m and m.W) or (love.graphics and love.graphics.getWidth()) or 640 + local H = (m and m.H) or (love.graphics and love.graphics.getHeight()) or 480 + + -- Scrim background + Theme.fill(0, 0, W, H, PAL.bg, 0.92) + local shielded = Kit.blockClicks + Kit.blockClicks = true + + local pad = math.floor(12 * s) + local modalW = math.floor(math.min(540 * s, W - 20 * s)) + local modalH = math.floor(math.min(360 * s, H - 20 * s)) + local px = math.floor((W - modalW) / 2) + local py = math.floor((H - modalH) / 2) + + Kit.card(px, py, modalW, modalH, true) + Kit.blockClicks = shielded + + local cy = py + pad + -- Title & hint + Kit.text("button", VirtualKeyboard.title, px + pad, cy, PAL.heading) + Kit.textRight("micro", VirtualKeyboard.mode == 4 + and "X: Bksp Start: Done B: Cancel" + or "L1/R1: Mode X: Bksp Y: Space Start: Done B: Cancel", + px + modalW - pad, cy + 2 * s, PAL.muted) + + cy = cy + Kit.textHeight("button") + math.floor(8 * s) + + -- Preview textfield box + local fieldH = math.floor(32 * s) + Theme.fillRounded(px + pad, cy, modalW - 2 * pad, fieldH, PAL.bg, 1) + Theme.strokeRounded(px + pad, cy, modalW - 2 * pad, fieldH, PAL.lineStrong, 1, 2) + + local shown = Kit.ellipsizeLeft("mono", VirtualKeyboard.text .. "_", modalW - 4 * pad) + Kit.text("mono", shown, px + pad + math.floor(8 * s), cy + math.floor(7 * s), PAL.heading) + + cy = cy + fieldH + math.floor(10 * s) + + -- Keyboard Rows + local rows = currentRows() + local rowH = math.floor(30 * s) + local gap = math.floor(4 * s) + local availableW = modalW - 2 * pad + + for rIdx, rData in ipairs(rows) do + local totalUnits = 0 + for _, key in ipairs(rData) do + totalUnits = totalUnits + (type(key) == "table" and (key.w or 1) or 1) + end + + local unitW = (availableW - (gap * (#rData - 1))) / totalUnits + local kx = px + pad + + for cIdx, key in ipairs(rData) do + local units = type(key) == "table" and (key.w or 1) or 1 + local kw = math.floor(unitW * units) + local isSelected = (VirtualKeyboard.row == rIdx and VirtualKeyboard.col == cIdx) + + local label = type(key) == "table" and key.label or key + local isSpecial = type(key) == "table" + + -- Click / touch detection + if Kit.press(kx, cy, kw, rowH) then + VirtualKeyboard.row = rIdx + VirtualKeyboard.col = cIdx + triggerKey(key) + end + + if isSelected then + Theme.fillRounded(kx, cy, kw, rowH, PAL.ink, 1) + Kit.textCenterBold("small", label, kx, cy + math.floor(6 * s), kw, PAL.inverse) + else + Theme.fillRounded(kx, cy, kw, rowH, isSpecial and PAL.raised or PAL.surface, 1) + Theme.strokeRounded(kx, cy, kw, rowH, PAL.line, Theme.A.hairline, 1) + Kit.textCenter("small", label, kx, cy + math.floor(6 * s), kw, isSpecial and PAL.heading or PAL.text) + end + + kx = kx + kw + gap + end + + cy = cy + rowH + gap + end + + Kit.blockClicks = shielded +end + +return VirtualKeyboard diff --git a/tests/engine/kit_spinner_trail_bug2048.lua b/tests/engine/kit_spinner_trail_bug2048.lua new file mode 100644 index 000000000..0fd4d8f62 --- /dev/null +++ b/tests/engine/kit_spinner_trail_bug2048.lua @@ -0,0 +1,50 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; 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 = T.check +local Kit = require("src.ui.kit.Kit") + +local G = love.graphics +local rec, alpha = {}, 1 +local oldSet, oldLine = G.setColor, G.line + +G.setColor = function(r, g, b, a) alpha = a or 1 end +G.line = function(_, _, x2, y2) + rec[#rec + 1] = { a = alpha, ang = math.atan2(y2 - 100, x2 - 100) } +end + +local function sample(t) + rec = {} + Kit.scale = 1 + Kit.spinner(100, 100, 20, t) + local head, best = 1, -1 + for i, v in ipairs(rec) do + if v.a > best then best, head = v.a, i end + end + return rec, head +end + +local r0, h0 = sample(0.0) +check(#r0 == 12, "the ring is twelve ticks") +local _, h1 = sample(0.1) +check(h1 == (h0 % #r0) + 1, "the bright head advances clockwise one tick") + +local ahead = r0[(h0 % #r0) + 1] +local behind = r0[((h0 - 2) % #r0) + 1] +check(behind.a > ahead.a, "the fade trails the head instead of leading it") +check(ahead.a < 0.1, "the tick the head is about to reach is the dimmest") + +local prev, monotonic = r0[h0].a, true +for n = 1, #r0 - 1 do + local v = r0[((h0 - 1 - n) % #r0) + 1] + if v.a >= prev then monotonic = false end + prev = v.a +end +check(monotonic, "and keeps falling off all the way round the trail") + +G.setColor, G.line = oldSet, oldLine + +T.finish("kit spinner trail") diff --git a/tests/engine/kit_transition.lua b/tests/engine/kit_transition.lua new file mode 100644 index 000000000..aadc48d25 --- /dev/null +++ b/tests/engine/kit_transition.lua @@ -0,0 +1,114 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +love = love or require("tests.love_stub") + +local clock = 0 +love.timer = love.timer or {} +local realGetTime = love.timer.getTime +love.timer.getTime = function() return clock end + +local Transition = require("src.ui.kit.Transition") +local Kit = require("src.ui.kit.Kit") + +local function near(a, b, tol) + return math.abs(a - b) <= (tol or 1e-9) +end + +eq(Transition.ease(0), 0, "the easing starts at zero") +eq(Transition.ease(1), 1, "and ends at one") +check(Transition.ease(0.5) > 0.5, "easeOutCubic runs ahead of linear") +eq(Transition.ease(-1), 0, "a negative input clamps to zero") +eq(Transition.ease(2), 1, "an overshoot clamps to one") + +Transition.reset() +Transition.reduceMotion = false +Transition.armed = false + +clock = 10 +check(not Transition.start("tabs", "tab", { dir = 1 }), + "an unarmed launcher does not animate") +check(not Transition.active(), "so nothing is running") +eq(Transition.progress("tabs"), 1, "and the layer reads as finished") + +Transition.armed = true +clock = 10 +check(Transition.start("tabs", "tab", { dir = -1, from = "red", to = "blue" }), + "an armed launcher starts the tab slide") +check(Transition.active("tabs"), "which is active on its own layer") +check(Transition.active(), "and answers the any-layer question") +eq(Transition.kind("tabs"), "tab", "the kind is kept for the draw") +eq(Transition.dir("tabs"), -1, "and so is the direction") +eq(Transition.progress("tabs"), 0, "progress starts at zero") + +clock = 10 + Transition.DURATIONS.tab / 2 +Transition.update() +check(Transition.progress("tabs") > 0.5, + "halfway through the clock the eased progress is past half") +check(Transition.active("tabs"), "and the layer is still running") + +clock = 10 + Transition.DURATIONS.tab * 1.5 +Transition.update() +eq(Transition.progress("tabs"), 1, "the end of the duration is progress 1") +check(not Transition.active("tabs"), "and the layer retires itself") +eq(Transition.get("tabs"), nil, "a retired layer hands back no record") + +-- ------------------------------------------------- one per layer, replaced + +clock = 20 +Transition.start("online", "push", { dir = 1, from = "home", to = "play" }) +clock = 20 + Transition.DURATIONS.push / 2 +Transition.update() +check(near(Transition.progress("online"), Transition.ease(0.5)), + "the online layer eases off its own start time") +Transition.start("online", "pop", { dir = -1, from = "play", to = "home" }) +eq(Transition.kind("online"), "pop", "a second start replaces the first") +eq(Transition.progress("online"), 0, "and restarts its clock") +eq(Transition.dir("online"), -1, "with the new direction") + +Transition.start("modal", "in") +check(Transition.active("modal") and Transition.active("online"), + "layers run independently") +eq(Transition.DURATIONS["in"], 0.12, "a modal pops in over 120 ms") +eq(Transition.DURATIONS.out, 0.09, "and out over 90 ms") +eq(Transition.DURATIONS.tab, 0.18, "a tab change takes 180 ms") +eq(Transition.DURATIONS.push, 0.16, "an online push takes 160 ms") +eq(Transition.DURATIONS.pop, 0.16, "and so does a pop") + +clock = 30 +Transition.update() +check(not Transition.active(), "every layer retires once its time is up") + +-- ------------------------------------------------------------ input block + +clock = 40 +Transition.start("tabs", "tab", { dir = 1 }) +Kit.beginFrame(0, 0, false, 0) +check(Kit.blockClicks, "a running transition shields the frame from clicks") +Kit.endFrame() + +clock = 40 + Transition.DURATIONS.tab * 1.5 +Kit.beginFrame(0, 0, false, 0) +check(not Kit.blockClicks, "and lets go on the frame it ends") +Kit.endFrame() + +-- ----------------------------------------------------------- reduce motion + +Transition.reduceMotion = true +clock = 50 +check(not Transition.start("modal", "in"), "reduce motion refuses to animate") +check(not Transition.active("modal"), "so no transition is running") +eq(Transition.progress("modal"), 1, "and the layer snaps to done") +eq(Transition.kind("modal"), nil, "with no kind for the draw to honour") + +Transition.reduceMotion = false +Transition.reset() +Transition.armed = false +love.timer.getTime = realGetTime + +T.finish("kit transition") diff --git a/tests/engine/native_file_picker.lua b/tests/engine/native_file_picker.lua new file mode 100644 index 000000000..010bf08a2 --- /dev/null +++ b/tests/engine/native_file_picker.lua @@ -0,0 +1,68 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local Host=require('src.core.HostShell') +local oldSystem=love.system;love.system=love.system or{} +local osName='UWP';local osGet=love.system.getOS;love.system.getOS=function()return osName end +local oldGame=package.loaded['src.core.Game'];local F=require('src.core.FilePicker') +T.eq(package.loaded['src.core.Game'],oldGame,'import cannot bootstrap Game') +local oldPop,oldHas=Host.popen,Host.hasCommand +local probes,closed={},0 +Host.popen=function(cmd)probes[#probes+1]=cmd;return{read=function()return '/usr/bin/zenity\n'end,close=function()closed=closed+1 end}end +for _,os in ipairs{'UWP','NX','Android','iOS','Web','Unknown'}do + osName=os;T.eq(Host.hasCommand('zenity'),false);T.eq(F.available(),false) + T.eq(F.commands('Choose',F.IMAGE),nil);T.eq(F.open('Choose',F.IMAGE),nil) +end +T.eq(#probes,0,'unsupported hosts never probe or spawn') +osName='Linux' +for _,name in ipairs{'zenity;touch nope','a b','../tool','-bad','$(bad)','x\nfoo','x%PATH%'}do T.eq(Host.hasCommand(name),false)end +T.eq(#probes,0);T.eq(Host.hasCommand('zenity'),true);T.eq(closed,1) +Host.popen=function()return{read=function()error('owned probe read failure')end,close=function()closed=closed+1 end}end +T.eq(Host.hasCommand('zenity'),false);T.eq(closed,2,'failed probe closes pipe') +local available={zenity=true,kdialog=true};Host.hasCommand=function(n)return available[n]or false end +local kind={label='QA sprite package',exts={'SPRITEPACK','spritepack'}} +local prompt='QA \' " $(do-not-run) `literal` %PATH% ü' +for _,os in ipairs{'OS X','Windows','Linux'}do + osName=os;T.eq(F.available(),true);local cmds=assert(F.commands(prompt,kind));T.eq(#cmds,1) + if os=='OS X'then + T.check(cmds[1]:find('osascript -e ',1,true)==1);T.check(cmds[1]:find("'\\''",1,true)~=nil) + elseif os=='Windows'then + T.eq(cmds[1]:find('do-not-run',1,true),nil,'untrusted UTF-8 is data') + T.check(cmds[1]:find('[Text.Encoding]::UTF8.GetString([byte[]]@(',1,true)~=nil) + T.check(cmds[1]:find('if($ascii){[Console]::Write($p)}else{',1,true)~=nil,'large ASCII file fast path') + T.check(cmds[1]:find('[Guid]::NewGuid()',1,true)~=nil,'unique non-ASCII staging') + T.check(cmds[1]:find('-ErrorAction Stop',1,true)~=nil,'failed staging cannot return stale data') + else T.check(cmds[1]:find('zenity ',1,true)==1)end +end +osName='Linux';available.zenity=false;T.check(F.commands('QA',kind)[1]:find('kdialog ',1,true)==1) +available.kdialog=false;T.eq(F.available(),false);T.eq(F.commands('QA',kind),nil);available.zenity=true +T.eq(F.matches('C:\\QA\\FILE.SPRITEPACK',kind),true);T.eq(F.matches('file.spritepack.exe',kind),false) +T.eq(F.matches('image.JPEG'),true);T.eq(F.basename('C:\\QA\\image.png'),'image.png');T.eq(F.basename('/qa/a.spritepack'),'a.spritepack') +for _,bad in ipairs{{exts={}},{exts={'bad;cmd'}},{exts={'bad"x'}},{exts={'bad x'}},{exts={'png'},label='x|*.sav'}}do + T.eq(F.commands('QA',bad),nil);T.eq(F.open('QA',bad),nil) +end +T.eq(F.commands('bad\0title',kind),nil);T.eq(F.open(nil,kind),nil);T.eq(F.open(string.rep('x',513),kind),nil) +local mouse,event,timer=love.mouse,love.event,love.timer;local held,pumps,launches=true,0,0 +love.mouse={isDown=function()return held end};love.event={pump=function()pumps=pumps+1;held=false end} +love.timer={getTime=function()return 0 end,sleep=function()end} +Host.popen=function() + T.eq(held,false,'pointer released before native dialog');launches=launches+1 + return{read=function()return ' /owned-qa/a.spritepack\n'end,close=function()closed=closed+1 end} +end +T.eq(F.open('QA pick',kind),'/owned-qa/a.spritepack');T.eq(launches,1);T.eq(pumps,1) +Host.popen=function()launches=launches+1;return{read=function()return ''end,close=function()closed=closed+1 end}end +T.eq(F.open('QA cancel',kind),nil);T.eq(launches,2,'cancel cannot launch another chooser') +local beforeClose=closed +Host.popen=function()return{read=function()error('owned dialog read failure')end,close=function()closed=closed+1 end}end +T.eq(F.open('QA failure',kind),nil);T.eq(closed,beforeClose+1) +love.mouse,love.event,love.timer=mouse,event,timer +local oldOpen=io.open;local reads,closes=0,0 +io.open=function(path,mode) + T.eq(path,'owned-qa-byte-probe');T.eq(mode,'rb') + return{read=function()reads=reads+1;return '\0binary\255'end,close=function()closes=closes+1 end} +end +T.eq(F.read('owned-qa-byte-probe'),'\0binary\255');T.eq(reads,1);T.eq(closes,1) +io.open=function()return{read=function()error('owned read failure')end,close=function()closes=closes+1 end}end +local bytes,err=F.read('owned-qa-byte-probe');T.eq(bytes,nil);T.check(err:find('owned read failure',1,true)~=nil);T.eq(closes,2) +io.open=oldOpen;Host.popen,Host.hasCommand=oldPop,oldHas;love.system.getOS=osGet;love.system=oldSystem +T.eq(package.loaded['src.core.Game'],oldGame,'no session/save owner introduced') +T.finish('native picker commands, one-backend cancellation, shared pointer release and readonly bytes') diff --git a/tests/engine/skin_studio_image_import.lua b/tests/engine/skin_studio_image_import.lua new file mode 100644 index 000000000..3e75860da --- /dev/null +++ b/tests/engine/skin_studio_image_import.lua @@ -0,0 +1,122 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Skin Studio art import: the native image picker behind the "Import" buttons +-- (bezel / idle / pressed), and the alt-tab crash that took the studio down -- +-- love.focus fell through to Game:focus with no game booted. +-- luajit tests/engine/skin_studio_image_import.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 FilePicker = require("src.core.FilePicker") +local TouchSkin = require("src.core.TouchSkin") +local Studio = require("src.ui.SkinStudio") + +local function session() + Studio.skin = TouchSkin.newSkin("t") + Studio.skinIdField = "t" + Studio.pageIndex = 1 + Studio.selected = nil + Studio.canvasIndex = 1 + Studio.drag = nil + Studio.dirty = false + Studio.status = nil + Studio.testing = false + Studio.images = {} + Studio.imageTarget = "idle" + return Studio.skin +end + +-- ------------------------------------------------------------ file picker + +check(FilePicker.matches("bezel.png", FilePicker.IMAGE), "png is art") +check(FilePicker.matches("BEZEL.PNG", FilePicker.IMAGE), "case does not matter") +check(FilePicker.matches("shot.jpg", FilePicker.IMAGE), "jpg is art") +check(FilePicker.matches("shot.jpeg", FilePicker.IMAGE), "jpeg is art") +check(not FilePicker.matches("skin.zip", FilePicker.IMAGE), "a zip is not art") +eq(FilePicker.basename("/home/me/art/bezel.png"), "bezel.png", "posix basename") +eq(FilePicker.basename("C:\\Users\\me\\bezel.png"), "bezel.png", "windows basename") +eq(FilePicker.basename("bezel.png"), "bezel.png", "a bare name is its own basename") + +-- ----------------------------------------------------------- import target + +session() +Studio.addControl() +local ctl = Studio.selectedControl() +check(Studio.adoptImage("bezel.png", "PNGDATA", "bezel"), "bezel import succeeds") +eq(Studio.page().imagePath, "img/bezel.png", "bezel art lands on the page") +eq(ctl.imagePath, nil, "and not on the selected control") +check(Studio.dirty, "importing marks the skin dirty") +check(Studio.status:find("bezel", 1, true) ~= nil, "the status names the target") + +check(Studio.adoptImage("a_button.png", "PNGDATA", "idle"), "idle import succeeds") +eq(ctl.imagePath, "img/a_button.png", "idle art lands on the control") +eq(Studio.page().imagePath, "img/bezel.png", "and leaves the bezel alone") + +check(Studio.adoptImage("a_down.png", "PNGDATA", "pressed"), "pressed import succeeds") +eq(ctl.pressedImagePath, "img/a_down.png", "pressed art lands on the control") +eq(ctl.imagePath, "img/a_button.png", "idle art survives") + +check(not Studio.adoptImage("notes.txt", "junk", "bezel"), "non-art is refused") +eq(Studio.page().imagePath, "img/bezel.png", "and changes nothing") + +-- the imported files are listed for the cycle buttons +local listed = {} +for _, rel in ipairs(Studio.images or {}) do listed[rel] = true end +check(listed["img/bezel.png"], "the imported bezel joins the image list") + +-- -------------------------------------------------------- picker plumbing + +local realOpen, realRead, realAvailable = FilePicker.open, FilePicker.read, FilePicker.available +FilePicker.available=function()return true end +local asked +FilePicker.open = function(prompt, kind) + asked = { prompt = prompt, kind = kind } + return "/tmp/some folder/frame.png" +end +FilePicker.read = function() return "PNGDATA" end + +session() +Studio.importImageFile("bezel") +check(asked ~= nil, "the bezel button opens a picker") +eq(asked.kind, FilePicker.IMAGE, "and asks for images, not archives") +check(asked.prompt:lower():find("bezel", 1, true) ~= nil, "with a bezel prompt") +eq(Studio.page().imagePath, "img/frame.png", "the pick becomes the bezel") +eq(Studio.imageTarget, "bezel", "and the target sticks for the cycle button") + +-- no control selected: an art import says so instead of writing to the page +session() +asked = nil +Studio.importImageFile("idle") +check(asked == nil, "no picker opens without a control to paint") +eq(Studio.page().imagePath, nil, "and the bezel is not overwritten") +check(Studio.status ~= nil, "the studio explains why") + +-- a cancelled dialog leaves the skin exactly as it was +session() +FilePicker.open = function() return nil end +Studio.importImageFile("bezel") +eq(Studio.page().imagePath, nil, "cancelling imports nothing") +check(not Studio.dirty, "and does not dirty the skin") + +FilePicker.open, FilePicker.read = realOpen, realRead +FilePicker.available=realAvailable + +-- --------------------------------------------------------- focus / alt-tab + +session() +Studio.drag = { kind = "control-move", mx = 1, my = 1, bx = 0, by = 0, bw = 1, bh = 1 } +Studio.clicked = true +Studio.focus(false) +eq(Studio.drag, nil, "losing focus drops the in-flight drag") +eq(Studio.clicked, false, "and the queued click") +check(pcall(Studio.visible, false), "minimizing is survivable too") + +-- Actual native main.lua event ownership is exercised by +-- skin_studio_native_host.lua; a text scan of a different host's variable +-- names would not prove that this engine dispatches those events. + +T.finish("skin_studio_image_import") diff --git a/tests/engine/skin_studio_library_reach_bug1912.lua b/tests/engine/skin_studio_library_reach_bug1912.lua new file mode 100644 index 000000000..a8e5f724c --- /dev/null +++ b/tests/engine/skin_studio_library_reach_bug1912.lua @@ -0,0 +1,176 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; 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 Studio = require("src.ui.SkinStudio") +local Kit = require("src.ui.kit.Kit") + +local oldDims = love.graphics.getDimensions +local oldSafe = love.window.getSafeArea +local oldThumb, oldSummary = Studio.libraryThumb, Studio.skinSummary +Studio.libraryThumb = function() return nil end +Studio.skinSummary = function() return "12 buttons 2 pages" end + +local SIZES = { + { 1600, 720, "1600x720 landscape phone" }, + { 2400, 1080, "2400x1080 landscape phone" }, + { 1280, 720, "1280x720" }, + { 1920, 1080, "1920x1080" }, + { 720, 1600, "720x1600 portrait phone" }, + { 400, 800, "400x800 small portrait" }, +} + +local function open(w, h, n) + love.graphics.getDimensions = function() return w, h end + love.window.getSafeArea = function() return 0, 0, w, h end + Studio.skin = TouchSkin.newSkin("t") + Studio.mode = "library" + Studio.modal, Studio.confirm = nil, nil + Studio.available = {} + for i = 1, n do + Studio.available[i] = { id = "skin" .. i, source = "user" } + end + Studio.libraryScroll, Studio.libraryScrollMax = 0, 0 + Studio.libraryPress = nil +end + +local function inside(r, f) + return r.x >= f.x - 0.5 and r.y >= f.y - 0.5 + and r.x + r.w <= f.x + f.w + 0.5 and r.y + r.h <= f.y + f.h + 0.5 +end + +local function tappable(r) + if not r.clip then return { x = r.x, y = r.y, w = r.w, h = r.h } end + local x1 = math.max(r.x, r.clip.x) + local y1 = math.max(r.y, r.clip.y) + local x2 = math.min(r.x + r.w, r.clip.x + r.clip.w) + local y2 = math.min(r.y + r.h, r.clip.y + r.clip.h) + if x2 <= x1 or y2 <= y1 then return nil end + return { x = x1, y = y1, w = x2 - x1, h = y2 - y1 } +end + +for _, size in ipairs(SIZES) do + local w, h, label = size[1], size[2], size[3] + local frame = { x = 0, y = 0, w = w, h = h } + for _, n in ipairs({ 3, 9 }) do + open(w, h, n) + Kit.audit = {} + check(pcall(Studio.draw), label .. " with " .. n .. " skins draws") + + local escaped = nil + for _, r in ipairs(Kit.audit or {}) do + local vis = tappable(r) + if vis and not inside(vis, frame) then escaped = r.label end + end + eq(escaped, nil, label .. "/" .. n + .. ": no tappable control escapes the window") + + local maxScroll = Studio.libraryScrollMax or 0 + local reached = {} + local step = Kit.scrollStep() + local offset = 0 + while true do + Studio.libraryScroll = offset + Kit.audit = {} + check(pcall(Studio.draw), label .. "/" .. n + .. ": draws at scroll " .. math.floor(offset)) + local view = Studio.libraryView + for id, r in pairs(Studio.libraryRects or {}) do + if inside(r, view) and inside(r, frame) then reached[id] = true end + end + if offset >= maxScroll then break end + offset = math.min(maxScroll, offset + step) + end + local missing = nil + for i = 1, n do + if not reached["skin" .. i] then missing = "skin" .. i end + end + eq(missing, nil, label .. "/" .. n + .. ": every skin's Select can be scrolled fully into view") + end +end + +Kit.audit = nil + +do + open(1600, 720, 3) + Studio.draw() + eq(Studio.libraryScrollMax, 0, "three skins fit a 1600x720 window with no scroll") + local view = Studio.libraryView + for i = 1, 3 do + local r = Studio.libraryRects["skin" .. i] + check(r ~= nil and inside(r, view), + "skin" .. i .. "'s Select is on screen at rest") + end +end + +do + open(1600, 720, 9) + Studio.draw() + check((Studio.libraryScrollMax or 0) > 0, + "nine skins overflow the same window and become scrollable") +end + + +do + open(1600, 720, 9) + Studio.draw() + local maxScroll = Studio.libraryScrollMax + local x, y = 800, 400 + + Studio.clicked = false + Studio.touchpressed("f1", x, y) + eq(Studio.clicked, false, "a press in the library does not dispatch a tap") + check(Studio.libraryPress ~= nil, "it arms a drag instead") + Studio.touchmoved("f1", x, y - 200) + check(Studio.libraryScroll > 0, "dragging up scrolls the list") + eq(Studio.libraryScroll, math.min(200, maxScroll), + "by exactly the distance the finger travelled, clamped to the extent") + Studio.touchreleased("f1", x, y - 200) + eq(Studio.clicked, false, "and the drag never becomes a tap on release") + eq(Studio.libraryPress, nil, "the press is cleared") + + Studio.touchmoved("f1", x, y - 400) + eq(Studio.libraryScroll, math.min(200, maxScroll), + "a move after release moves nothing") + + Studio.libraryScroll = 0 + Studio.clicked = false + Studio.touchpressed("f1", x, y) + Studio.touchmoved("f1", x + 2, y - 2) + eq(Studio.libraryScroll, 0, "a jitter inside the tap slop is not a drag") + Studio.touchreleased("f1", x + 2, y - 2) + eq(Studio.clicked, true, "a press with no travel dispatches the tap on release") +end + +do + open(1600, 720, 9) + Studio.draw() + Studio.libraryScroll = 0 + Studio.touchpressed("f1", 800, 400) + Studio.touchmoved("f1", 800, 400 + 500) + eq(Studio.libraryScroll, 0, "dragging down past the top clamps at zero") + Studio.touchreleased("f1", 800, 900) +end + +do + open(1600, 720, 3) + Studio.mode = "editor" + Studio.clicked = false + Studio.libraryPress = nil + Studio.mousepressed(800, 400, 1) + eq(Studio.clicked, true, "the editor still dispatches its tap on press") + eq(Studio.libraryPress, nil, "and never arms a library drag") +end + +Studio.libraryThumb, Studio.skinSummary = oldThumb, oldSummary +love.graphics.getDimensions = oldDims +love.window.getSafeArea = oldSafe + +T.finish("My Skins library reach and scroll (#1912)") diff --git a/tests/engine/skin_studio_native_host.lua b/tests/engine/skin_studio_native_host.lua new file mode 100644 index 000000000..aeae68cbf --- /dev/null +++ b/tests/engine/skin_studio_native_host.lua @@ -0,0 +1,138 @@ +-- Actual native main callbacks, editor host handoff and real studio state. +-- Constructor is isolated; no cartridge import, player profile or OS IO. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Save=require('src.core.SaveData') +local files={} +local fs=T.sdk.memfs(files) +fs.createDirectory=function()return true end +fs.remove=function(p)files[p]=nil;return true end +fs.getSaveDirectory=function()return '/memory-only-studio-host'end +love.filesystem=fs;Save.persistenceFs=function()return fs end +local osName='Linux' +love.system=love.system or {};love.system.getOS=function()return osName end +local newImage=love.graphics.newImage +love.graphics.newImage=function(...) + local image=newImage(...) + function image:setFilter(min,mag)self.filter={min,mag}end + return image +end +local Runtime=require('src.mods.Runtime') +Runtime.install(require('src.mods.Events').new(),require('src.mods.Hooks').new(),{}) +local Host=require('src.core.HostShell') +local launcher +local oldHook=Host.setLauncherHook +Host.setLauncherHook=function(fn)launcher=fn;return oldHook(fn)end +dofile('main.lua');Host.setLauncherHook=oldHook +local function up(fn,wanted) + for i=1,100 do + local name,value=debug.getupvalue(fn,i) + if not name then break end + if name==wanted then return value end + end + error('native host has no '..wanted) +end +local open=up(launcher,'openLauncher') +local Importer=require('src.import.RomImporter') +local oldNew,options=Importer.new +local played=0 +local host=setmetatable({tab='crystal',ready={crystal=true},_padCursor={x=90,y=80}, + _padAxis={leftx=1},_padDir={dpright=true},_rawHatDirs={},_padCursorActive=true, + _slotPress={},_touchGesture={},onComplete=function(v) + T.eq(v,'crystal','studio Play retains selected native cartridge');played=played+1 + end},{__index=Importer}) +Importer.new=function(_,opts)options=opts;return host end +open(false);Importer.new=oldNew +T.check(type(options.onEditTouchSkins)=='function','native launcher actually supplies studio action') +options.onEditTouchSkins() +local Studio=require('src.ui.SkinStudio') +local Skin=require('src.core.TouchSkin') +local Pad=require('src.ui.PadCursor') +local Kit=require('src.ui.kit.Kit') +T.eq(up(love.keypressed,'TouchEditor'),Studio,'actual native host installs real studio owner') +T.eq(up(love.keypressed,'Importer'),nil,'native launcher is suspended') +T.eq(next(host._padAxis),nil,'handoff drops old held axis') +T.eq(host._touchGesture,nil,'handoff drops old launcher gesture') +T.eq(Studio.mode,'library','native entry opens actual skin library') +T.eq(Studio.version,'crystal','editor retains selected cartridge') +love.gamepadaxis(nil,'leftx',1) +T.check(Pad.isActive(),'main forwards controller axis to real studio cursor') +Studio.drag={};Studio.clicked=true +love.focus(false) +T.eq(Studio.drag,nil,'actual focus event releases studio drag with no Game booted') +T.check(not Pad.isActive(),'actual focus event cancels held virtual cursor') +love.gamepadaxis(nil,'leftx',1);love.joystickremoved({}) +T.check(not Pad.isActive(),'device removal clears actual studio cursor') +love.visible(false);T.eq(Studio.pointerDown,false,'native visibility event drops gesture') +love.wheelmoved(0,-3);T.eq(Studio.wheel,-3,'actual wheel event reaches studio scroll owner') +Kit.layout(640,576);Kit.focus='native-name' +love.textinput('OWNED') +local text=Kit.textfield('native-name',0,0,200,40,'','Name') +T.eq(text,'OWNED','native text event reaches actual focused widget') +Kit.blur() +Studio.mode='editor';Studio.imageTarget='bezel' +local dropped={getFilename=function()return 'owned-host.png'end, + open=function()return true end,read=function()return 'OWNED-MEMORY-IMAGE'end,close=function()end} +love.filedropped(dropped) +T.eq(Studio.page().imagePath,'img/owned-host.png','actual file drop imports owned art into selected skin') +T.check(Studio.dirty,'actual import marks native editor dirty') +local previousSkin=Studio.skin +local archive={getFilename=function()return 'another.zip'end, + open=function()return true end,read=function()return 'PENDING-ARCHIVE'end,close=function()end} +love.filedropped(archive) +T.check(Studio.confirm~=nil,'native archive drop asks before discarding unsaved artwork') +T.eq(Studio.skin,previousSkin,'pending archive drop retains actual edited skin') +Studio.confirmNo() +T.eq(Studio.skin,previousSkin,'cancelling drop retains edits') +-- The editor must preserve native DS controls, in addition to Gen1's binds. +Studio.addControl();Studio.setBindSpec('x|y') +local Input=require('src.core.Input');Input:init() +local Touch=require('src.core.TouchControls');Touch:setPreview(false) +local w,h=love.graphics.getDimensions() +local cx,cy=Skin.controlGeometry(Studio.page(),Studio.selectedControl(),w,h) +T.check(Touch:touchpressed('ds-buttons',cx,cy),'studio-created native DS bind captures real contact') +T.check(Input:isDown('x') and Input:isDown('y'),'both DS buttons reach actual native input owner') +Touch:touchreleased('ds-buttons',cx,cy) +T.check(not Input:isDown('x') and not Input:isDown('y'),'DS bind release reaches native owner') +Touch:setPreview(true) +-- The studio's mock device occupies a rectangle inside the editor window. +-- Its Test input must use that same rectangle, not the full window surface. +Studio.setBindSpec('a');Studio.testing=true;Touch:setPreview(false) +Studio.lastCanvas={x=60,y=70,w=180,h=300} +local r=Studio.lastCanvas +cx,cy=Skin.controlGeometry(Studio.page(),Studio.selectedControl(),r.w,r.h,r.x,r.y) +Studio.mousepressed(cx,cy,1) +T.check(Input:isDown('a'),'Test input uses actual mock-device coordinates') +T.eq(Skin.surfaceRect,nil,'Test input restores outer skin surface') +Studio.mousereleased(cx,cy,1) +T.check(not Input:isDown('a'),'mock-device release clears native button') +Studio.testing=false;Touch:setPreview(true) +local calls=0 +local mouse=Studio.mousepressed +Studio.mousepressed=function(...)calls=calls+1;return mouse(...)end +for _,platform in ipairs{'iOS','Android'}do + osName=platform + love.touchpressed(platform,400,300) + local after=calls + love.mousepressed(400,300,1,true) + T.eq(calls,after,'synthetic '..platform..' mouse does not duplicate real touch') + love.touchreleased(platform,400,300) + T.eq(Studio.touchId,nil,'actual '..platform..' release ends finger ownership') +end +Studio.mousepressed=mouse;osName='Linux' +Studio.mode='library';Studio.dirty=false +love.gamepadpressed(nil,'b') +T.eq(up(love.keypressed,'TouchEditor'),nil,'normal B closes actual native studio owner') +T.eq(up(love.keypressed,'Importer'),host,'normal close restores the same native launcher') +T.eq(next(host._padDir),nil,'close cannot restore stale held launcher direction') +T.eq(host._padCursor.x,90,'close preserves launcher cursor location') +T.check(host._padCursorActive,'close restores prior pad navigation mode') +options.onEditTouchSkins() +Studio.newSkin();Studio.skinIdField='native_host_play';Studio.play() +T.eq(played,0,'Play never starts a cartridge inside editor draw') +Studio.update(1/60) +T.eq(played,1,'deferred Play reaches actual native ready-cartridge admission') +T.eq(up(love.keypressed,'TouchEditor'),nil,'deferred Play unloads native editor before admission') +T.eq(Save.loadOptions().touchControls.skin,'native_host_play','Play persists actual selected native skin') +T.eq(Skin.active,nil,'unload relinquishes editor viewport before game boot') +T.finish('native studio launcher, callbacks and deferred play admission') diff --git a/tests/engine/skin_studio_test.lua b/tests/engine/skin_studio_test.lua new file mode 100644 index 000000000..d0ca75309 --- /dev/null +++ b/tests/engine/skin_studio_test.lua @@ -0,0 +1,388 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Skin Studio model layer (#1386): canvas presets, control editing, the +-- drag/resize math and the pixel coordinate fields. Drawing is exercised by +-- tests/drivers/skin_studio_shot.lua; this pins the state machine underneath. +-- luajit tests/engine/skin_studio_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 Studio = require("src.ui.SkinStudio") + +local function near(a, b, tol, msg) + check(math.abs(a - b) <= (tol or 1e-6), msg .. " (got " .. tostring(a) .. + ", want " .. tostring(b) .. ")") +end + +-- a studio session without touching options or the filesystem +local function session() + Studio.skin = TouchSkin.newSkin("t") + Studio.pageIndex = 1 + Studio.selected = nil + Studio.canvasIndex = 1 + Studio.viewZoom = 1 + Studio.aspectLock = true + Studio.drag = nil + Studio.dirty = false + Studio.images = {} + return Studio.skin +end + +-- ------------------------------------------------------------- new skin + +session() +eq(#Studio.skin.pages, 1, "a new skin starts with one page") +eq(#Studio.page().controls, 0, "and no controls") +check(Studio.page().viewport ~= nil, "and a screen cutout to place") +check(Studio.selectedControl() == nil, "nothing selected yet") + +-- ------------------------------------------------------------- controls + +Studio.addControl() +eq(#Studio.page().controls, 1, "addControl appends") +eq(Studio.selected, 1, "and selects what it added") +check(Studio.dirty, "editing marks the skin dirty") +local ctl = Studio.selectedControl() +eq(ctl.spec, "a", "new controls default to GB a") +eq(ctl.buttons[1], "a", "and carry the parsed bind") + +Studio.cycleBind(1) +check(ctl.spec ~= "a", "cycleBind moves off the current bind") +check(#ctl.buttons + #ctl.hotkeys + #ctl.keys > 0 or ctl.decorative, + "cycleBind reparses the bind") +Studio.cycleBind(-1) +eq(ctl.spec, "a", "cycleBind is reversible") + +Studio.duplicateControl() +eq(#Studio.page().controls, 2, "duplicate adds a second control") +eq(Studio.selected, 2, "and selects the copy") +check(Studio.page().controls[2] ~= Studio.page().controls[1], + "the copy is a separate table") +near(Studio.page().controls[2].x, Studio.page().controls[1].x + 0.04, 1e-6, + "the copy is offset so it is visible") + +Studio.deleteControl() +eq(#Studio.page().controls, 1, "delete removes it") +Studio.deleteControl() +eq(#Studio.page().controls, 0, "delete works down to empty") +eq(Studio.selected, nil, "and clears the selection") + +-- ------------------------------------------------------- canvas presets + +local ids = {} +for _, c in ipairs(Studio.CANVASES) do + if c.live then + check(c.id == "this_device", "the live preset is this screen") + else + check(c.w > 0 and c.h > 0, c.id .. " has real pixel dimensions") + end + check(not ids[c.id], c.id .. " appears once") + ids[c.id] = true +end +check(ids.phone_portrait, "a phone portrait preset exists") +check(ids.this_device, "a this-screen live preset exists") +check(ids.desktop_1080, "a desktop preset exists") +check(ids.ultrawide, "an ultrawide preset exists") +check(ids.sgb_border, "a Super Game Boy border preset exists") + +session() +local sgbIndex +for i, c in ipairs(Studio.CANVASES) do + if c.id == "sgb_border" then sgbIndex = i end +end +Studio.setCanvas(sgbIndex) +local canvas = Studio.canvas() +eq(canvas.w, 256, "SGB canvas is 256 wide") +eq(canvas.h, 224, "SGB canvas is 224 tall") +local vp = Studio.page().viewport +near(vp.x * 256, 48, 1e-6, "SGB screen sits 48px from the left") +near(vp.y * 224, 40, 1e-6, "SGB screen sits 40px from the top") +near(vp.w * 256, 160, 1e-6, "SGB screen is 160px wide") +near(vp.h * 224, 144, 1e-6, "SGB screen is 144px tall") + +-- the locked preset refuses to let the cutout be moved or removed +Studio.toggleViewport() +check(Studio.page().viewport ~= nil, "the SGB cutout cannot be toggled off") + +-- presets wrap rather than running off the end of the list +Studio.setCanvas(#Studio.CANVASES + 1) +eq(Studio.canvasIndex, 1, "canvas selection wraps") + +-- the preview must lay the page out the way the game will, so a preset never +-- overwrites the aspect the bezel art (or a cfg) already fixed +session() +local art = Studio.page() +art.aspect, art.aspectFromImage = 0.5625, true +Studio.setCanvas(1) +near(art.aspect, 0.5625, 1e-9, "bezel art keeps its own aspect on a preset switch") +local deckW, deckH = 900, 1700 +local _, dby, _, dbh = TouchSkin.pageBox(art, deckW, deckH) +check(dby > 0 and math.abs(dby + dbh - deckH) < 1e-6, + "so a preset taller than the art pins the deck low, as gameplay does") + +session() +local plain = Studio.page() +plain.aspect, plain.aspectFromImage, plain.aspectFromCfg = nil, nil, nil +Studio.setCanvas(1) +near(plain.aspect, Studio.canvas().w / Studio.canvas().h, 1e-9, + "a page with no art of its own still takes the preset's aspect") + +Studio.setCanvas(1) +Studio.viewZoom = 1 +local cx, cy, cw, ch = Studio.canvasRect(0, 0, 800, 600) +near(cw / ch, Studio.canvas().w / Studio.canvas().h, 1e-6, + "the mock device keeps its aspect") +check(cw <= 800 + 1e-6 and ch <= 600 + 1e-6, "and fits inside the workspace") +near(cx + cw / 2, 400, 1e-6, "centred horizontally") +near(cy + ch / 2, 300, 1e-6, "centred vertically") + +eq(Studio.zoomOut(), 0.75, "zoom out steps to 75%") +local zx, zy, zw, zh = Studio.canvasRect(0, 0, 800, 600) +near(zw, cw * 0.75, 1e-6, "zoom out shrinks the mock device") +near(zh, ch * 0.75, 1e-6, "on both axes") +near(zx + zw / 2, 400, 1e-6, "and stays centred") +near(zw / zh, cw / ch, 1e-6, "keeping the same aspect") +check(zw < cw, "so there is margin around the device for a larger screen hole") +eq(Studio.zoomIn(), 1, "zoom in restores fit") +eq(Studio.zoomOut(), 0.75) +eq(Studio.zoomOut(), 0.5) +eq(Studio.zoomOut(), 0.35) +eq(Studio.zoomOut(), 0.35, "zoom out stops at the smallest level") +eq(Studio.zoomFit(), 1, "fit snaps back to contain") + +local oldDims = love.graphics.getDimensions +love.graphics.getDimensions = function() return 1170, 2532 end +session() +local live = Studio.detectDeviceCanvas() +eq(live.id, "this_device", "detect this screen selects the live canvas") +eq(live.w, 1170, "at the window width") +eq(live.h, 2532, "and the window height") +eq(Studio.canvas().w / Studio.canvas().h, 1170 / 2532, + "so the mock device is this screen's form factor") +Studio.matchOrient = true +Studio.syncCanvasToPage() +eq(Studio.canvas().id, "this_device", + "Match canvas leaves the live screen selected") +love.graphics.getDimensions = oldDims + +-- ----------------------------------------------------------- touch bridge + +session() +Studio.lastCanvas = { x = 0, y = 0, w = 100, h = 100 } +Studio.touchpressed("finger", 50, 50) +eq(Studio.touchId, "finger", "the first finger owns the Studio gesture") +check(Studio.pointerDown, "a touch is tracked as a held editor pointer") +Studio.touchmoved("finger", 60, 50) +Studio.touchreleased("finger", 60, 50) +eq(Studio.touchId, nil, "lifting clears the captured finger") +check(not Studio.pointerDown, "and releases the editor pointer") + +-- ---------------------------------------------------------------- drag + +session() +Studio.addControl() +ctl = Studio.selectedControl() +local r = { x = 0, y = 0, w = 1000, h = 1000 } +local startX, startY = ctl.x, ctl.y + +Studio.drag = { kind = "control-move", mx = 500, my = 500, + bx = 420, by = 455, bw = 160, bh = 90 } +Studio.updateDrag(600, 500, r) +near(ctl.x, startX + 0.1, 1e-6, "dragging right moves the control right") +near(ctl.y, startY, 1e-6, "and leaves y alone") + +Studio.drag = { kind = "control-resize", handle = "se", mx = 500, my = 500, + bx = 400, by = 400, bw = 100, bh = 100 } +Studio.updateDrag(600, 600, r) +near(ctl.rangeX * 2 * r.w, 200, 1e-6, "the se handle widens the control") +near(ctl.rangeY * 2 * r.h, 200, 1e-6, "and heightens it") + +-- the viewport keeps the Game Boy's 10:9 while the lock is on +Studio.aspectLock = true +Studio.selected = nil +Studio.drag = { kind = "viewport-resize", handle = "se", mx = 0, my = 0, + bx = 0, by = 0, bw = 400, bh = 400 } +Studio.updateDrag(100, 0, r) +local v = Studio.page().viewport +near(v.w / v.h, 160 / 144, 1e-6, "10:9 lock holds the screen aspect") + +Studio.aspectLock = false +Studio.drag = { kind = "viewport-resize", handle = "se", mx = 0, my = 0, + bx = 0, by = 0, bw = 400, bh = 400 } +Studio.updateDrag(100, 200, r) +v = Studio.page().viewport +near(v.w * r.w, 500, 1e-6, "unlocked, width follows the pointer") +near(v.h * r.h, 600, 1e-6, "and height is free") + +Studio.drag = { kind = "viewport-move", mx = 0, my = 0, + bx = 0, by = 0, bw = 100, bh = 100 } +Studio.updateDrag(-250, 1100, r) +v = Studio.page().viewport +check(v.x < 0, "a screen anchor can move past the left canvas edge") +check(v.y > 1, "a screen anchor can move past the bottom canvas edge") + +-- controls never escape the canvas +Studio.selected = 1 +Studio.drag = { kind = "control-move", mx = 0, my = 0, + bx = 0, by = 0, bw = 100, bh = 100 } +Studio.updateDrag(-100000, -100000, r) +check(ctl.x >= 0 and ctl.y >= 0, "a control cannot be dragged off the top-left") +Studio.updateDrag(100000, 100000, r) +check(ctl.x <= 1 and ctl.y <= 1, "or off the bottom-right") + +-- ------------------------------------------------------- pixel fields + +session() +Studio.addControl() +ctl = Studio.selectedControl() +canvas = Studio.canvas() +eq(canvas.w, 1080, "phone portrait is 1080 wide") + +Studio.commitField("numW", "216") +near(ctl.rangeX * 2 * canvas.w, 216, 1e-6, "W is typed in canvas pixels") +Studio.commitField("numX", "100") +near((ctl.x - ctl.rangeX) * canvas.w, 100, 1e-4, "X is the left edge in pixels") +Studio.commitField("numH", "180") +near(ctl.rangeY * 2 * canvas.h, 180, 1e-6, "H is typed in canvas pixels") +Studio.commitField("numY", "640") +near((ctl.y - ctl.rangeY) * canvas.h, 640, 1e-4, "Y is the top edge in pixels") +-- X must not have drifted when the later fields were set +near((ctl.x - ctl.rangeX) * canvas.w, 100, 1e-4, "X survives edits to the others") + +local keptX = ctl.x +Studio.commitField("numX", "not a number") +near(ctl.x, keptX, 1e-9, "garbage in a field is ignored") + +-- ---------------------------------------------------------------- pages + +session() +Studio.addPage() +eq(#Studio.skin.pages, 2, "addPage appends a page") +eq(Studio.pageIndex, 2, "and switches to it") +eq(Studio.page().name, "page2", "the new page is named in sequence") +Studio.addControl() +eq(#Studio.skin.pages[2].controls, 1, "controls land on the active page") +eq(#Studio.skin.pages[1].controls, 0, "and not on the other one") + +-- page orientation lock follows the canvas when Match canvas is on (#1503) +session() +Studio.canvasIndex = 1 +Studio.matchOrient = true +eq(Studio.cyclePageOrient(1), "portrait", "cycle starts at portrait from unlocked") +eq(Studio.page().orient, "portrait", "and stores the lock on the page") +eq(Studio.page().name, "portrait", "a generic page is renamed so play can auto-rotate") +eq(Studio.canvas().id, "phone_portrait", "and the canvas stays portrait") + +Studio.addPage() +eq(Studio.cyclePageOrient(1), "portrait", "the new page starts unlocked, first lock is portrait") +Studio.cyclePageOrient(1) +eq(Studio.page().orient, "landscape", "second cycle is landscape") +eq(Studio.canvas().id, "phone_landscape", "Match canvas flips the mock device with the page") + +Studio.pageIndex = 1 +Studio.syncCanvasToPage() +eq(Studio.canvas().id, "phone_portrait", "switching back to the portrait page restores portrait canvas") + +Studio.setCanvas(2) +eq(Studio.pageIndex, 2, "picking a landscape canvas selects the landscape page") + +Studio.matchOrient = false +Studio.pageIndex = 1 +Studio.setCanvas(2) +eq(Studio.pageIndex, 1, "Match canvas off leaves the page when the device changes") +eq(Studio.canvas().id, "phone_landscape", "and still honours the canvas click") + +-- a RetroArch overlay that already auto-rotates locks itself on open (#1503) +session() +Studio.matchOrient = false +Studio.canvasIndex = 2 +Studio.skin = assert(TouchSkin.parse([[ +overlays = 2 +overlay0_name = "portrait" +overlay0_full_screen = true +overlay0_descs = 1 +overlay0_desc0 = "a,0.5,0.5,radial,0.05,0.05" +overlay1_name = "landscape" +overlay1_full_screen = true +overlay1_descs = 1 +overlay1_desc0 = "a,0.5,0.5,radial,0.05,0.05" +]])) +Studio.pageIndex = 1 +check(Studio.applyImportedOrient(), "import of a portrait/landscape pair is automatic") +check(Studio.matchOrient, "and turns Match canvas on") +eq(Studio.page().name, "landscape", "keeping the landscape canvas already on screen") +eq(Studio.page().orient, "landscape", "with the page already locked") + +-- --------------------------------------------------------------- clone + +-- An owned memory-only bundled fixture, rather than excluded reference art. +local seed=TouchSkin.newSkin('owned_clone') +seed.pages[1].controls={TouchSkin.newControl('a',.7,.8,.1,.1,'rect')} +love.filesystem.write('assets/skins/owned_clone/skin.lua',TouchSkin.serialize(seed)) +local source = TouchSkin.load("assets/skins/owned_clone", "owned_clone") +check(source ~= nil, "bundled skin loads for cloning") +if source then + local copy = TouchSkin.clone(source) + eq(#copy.pages, #source.pages, "clone keeps every page") + eq(#copy.pages[1].controls, #source.pages[1].controls, "and every control") + check(copy.pages[1] ~= source.pages[1], "pages are fresh tables") + check(copy.pages[1].controls[1] ~= source.pages[1].controls[1], + "controls are fresh tables") + eq(copy.pages[1].image, source.pages[1].image, "loaded images are shared, not reloaded") + copy.pages[1].controls[1].x = 0.123 + check(source.pages[1].controls[1].x ~= 0.123, + "editing the clone does not touch the loaded skin") +end + +-- ------------------------------------------------------------ play handoff + +-- Play used to call the host handler straight out of the Kit button, which +-- unloaded the studio inside its own draw pass; the rest of that frame then +-- indexed a nil skin and took the app down. The handoff is deferred to +-- update, and a torn-down studio has to survive a draw either way. +session() +Studio.available, Studio.images = {}, {} +Studio.skinIdField, Studio.onClose = "t", function() end +local fired, gotVersion = 0, nil +Studio.version = "red" +Studio.onPlay = function(v) + fired = fired + 1 + gotVersion = v + Studio.unload() +end + +Studio.play() +check(Studio.pendingPlay, "play queues the handoff") +eq(fired, 0, "play does not hand off during the click") +check(Studio.skin ~= nil, "and leaves the skin alive for the rest of the frame") +check(pcall(Studio.draw), "the frame that queued play still draws") + +Studio.update() +eq(fired, 1, "update performs the handoff") +eq(gotVersion, "red", "and passes the launcher tab through") +eq(Studio.skin, nil, "the studio is unloaded by then") +check(pcall(Studio.draw), "a torn-down studio still survives a draw") + +Studio.update() +eq(fired, 1, "the handoff does not repeat") + +-- unload must clear a queued handoff, or closing then reopening would boot +session() +Studio.onPlay = function() fired = fired + 1 end +Studio.play() +check(Studio.pendingPlay, "queued again") +Studio.unload() +check(not Studio.pendingPlay, "unload drops a queued handoff") +Studio.update() +eq(fired, 1, "closing the studio does not start the game") + +-- the studio is a desktop workspace; the launcher only offers it there +check(type(Studio.available_desktop) == "function", "desktop gate is exported") +check(Studio.available_desktop(), "headless/desktop reports available") + +T.finish("skin_studio") diff --git a/tests/engine/skin_studio_ux.lua b/tests/engine/skin_studio_ux.lua new file mode 100644 index 000000000..832aa150e --- /dev/null +++ b/tests/engine/skin_studio_ux.lua @@ -0,0 +1,387 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; 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 Studio = require("src.ui.SkinStudio") + +local function near(a, b, tol, msg) + check(math.abs(a - b) <= (tol or 1e-6), msg .. " (got " .. tostring(a) .. + ", want " .. tostring(b) .. ")") +end + +local function session() + Studio.skin = TouchSkin.newSkin("t") + Studio.skinIdField = "t" + Studio.pageIndex = 1 + Studio.selected = nil + Studio.canvasIndex = 1 + Studio.viewZoom = 1 + Studio.aspectLock = true + Studio.drag = nil + Studio.dirty = false + Studio.images = {} + Studio.thumbs = {} + Studio.available = {} + Studio.availableMeta = {} + Studio.undoStack, Studio.redoStack = {}, {} + Studio.undoTag, Studio.undoAt = nil, nil + Studio.modal, Studio.confirm = nil, nil + Studio.status, Studio.statusErr = nil, false + Studio.imageTarget = "idle" + return Studio.skin +end + +session() +check(not Studio.canUndo(), "a fresh session has nothing to undo") +Studio.addControl() +eq(#Studio.page().controls, 1, "a control was added") +check(Studio.canUndo(), "adding a control is undoable") +Studio.undo() +eq(#Studio.page().controls, 0, "undo takes the control back off") +check(Studio.canRedo(), "and offers a redo") +Studio.redo() +eq(#Studio.page().controls, 1, "redo puts it back") +check(not Studio.canRedo(), "the redo stack is spent") + +Studio.addControl() +check(not Studio.canRedo(), "a fresh edit clears the redo stack") + +session() +Studio.addControl() +local before = Studio.page().controls[1] +Studio.pushUndo() +before.x = 0.9 +Studio.undo() +check(Studio.page().controls[1] ~= before, + "undo restores a copy, not the edited table") +near(Studio.page().controls[1].x, 0.5, 1e-6, "with the pre-edit position") + +session() +for _ = 1, Studio.UNDO_CAP + 10 do Studio.pushUndo() end +eq(#Studio.undoStack, Studio.UNDO_CAP, "the undo stack is capped") + +session() +check(not Studio.undo(), "undo on an empty stack reports nothing to do") +check(not Studio.redo(), "and so does redo") + +local realIsDown = love.keyboard and love.keyboard.isDown +love.keyboard = love.keyboard or {} +local held = {} +love.keyboard.isDown = function(...) + for _, k in ipairs({ ... }) do if held[k] then return true end end + return false +end +session() +Studio.addControl() +Studio.addControl() +held.lctrl = true +Studio.keypressed("z") +eq(#Studio.page().controls, 1, "ctrl+Z undoes one step") +Studio.keypressed("z") +eq(#Studio.page().controls, 0, "and again") +held.lshift = true +Studio.keypressed("z") +eq(#Studio.page().controls, 1, "ctrl+shift+Z redoes instead of undoing further") +held.lshift = nil +Studio.keypressed("y") +eq(#Studio.page().controls, 2, "and ctrl+Y redoes as well") +held.lctrl = nil +if realIsDown then love.keyboard.isDown = realIsDown end + +session() +local ran = 0 +check(Studio.guard("lose it?", function() ran = ran + 1 end), + "a clean skin runs the action straight away") +eq(ran, 1, "and does not prompt") +check(Studio.confirm == nil, "no prompt is left up") + +Studio.dirty = true +check(not Studio.guard("lose it?", function() ran = ran + 1 end), + "a dirty skin defers the action") +eq(ran, 1, "the action has not run yet") +check(Studio.confirm ~= nil, "and a prompt is up") +Studio.confirmNo() +eq(ran, 1, "cancelling drops the action") +check(Studio.confirm == nil, "and closes the prompt") + +Studio.guard("lose it?", function() ran = ran + 1 end) +Studio.confirmYes() +eq(ran, 2, "confirming runs it") +check(Studio.confirm == nil, "and closes the prompt") + +session() +Studio.dirty = true +Studio.openLoadPicker() +check(Studio.confirm ~= nil, "Load prompts over unsaved work") +check(Studio.modal == nil, "and does not open the picker yet") +Studio.confirmYes() +check(Studio.modal ~= nil and Studio.modal.kind == "open", + "confirming opens the picker") +Studio.closeModal() + +eq(Studio.toggleBindPart("nul", "left"), "left", + "a bind starts from decoration") +eq(Studio.toggleBindPart("left", "up"), "left|up", + "directions combine in the canonical order") +eq(Studio.toggleBindPart("up", "left"), "left|up", + "and the order does not depend on which was added first") +eq(Studio.toggleBindPart("left|up", "up"), "left", + "toggling a part off removes it") +eq(Studio.toggleBindPart("left", "left"), "nul", + "removing the last part leaves decoration") +eq(Studio.toggleBindPart("a", "b"), "a|b", "buttons combine as well") +check(Studio.hasBindPart("left|down", "down"), "hasBindPart finds a part") +check(not Studio.hasBindPart("left|down", "up"), "and misses one that is absent") + +session() +check(not Studio.openBindPicker(), "the bind picker needs a selected control") +check(Studio.statusErr, "and says so as an error") +Studio.addControl() +check(Studio.openBindPicker(), "with a control selected it opens") +eq(Studio.modal.kind, "bind", "as the bind modal") +Studio.setBindSpec("start") +eq(Studio.selectedControl().spec, "start", "picking a bind writes the spec") +eq(Studio.selectedControl().buttons[1], "start", "and reparses it") +Studio.undo() +eq(Studio.selectedControl().spec, "a", "the bind change is undoable") +Studio.closeModal() + +Studio.toggleSelectedBindPart("b") +eq(Studio.selectedControl().spec, "a|b", "the combine chips build a pipe bind") +eq(#Studio.selectedControl().buttons, 2, "which fires both buttons") + +local specs = {} +for _, group in ipairs(Studio.BIND_GROUPS) do + check(#group.specs > 0, group.title .. " lists at least one bind") + for _, spec in ipairs(group.specs) do specs[spec] = true end +end +check(specs["a"] and specs["start"], "the GB buttons are reachable") +check(specs["overlay_previous"], "overlay_previous is reachable at last") +check(specs["pause_toggle"] and specs["exit_emulator"], + "so are the hotkeys the old cycle could not reach") +check(specs["key:escape"], "and a keyboard bind can be picked") +check(specs["key:-"] and specs["key:="] and specs["key:1"] + and specs["key:5"] and specs["key:f1"] and specs["key:f2"] + and specs["key:f10"], + "desktop hotkeys can be placed on a mobile skin button") +check(specs["nul"], "decoration is still an option") + +session() +Studio.addControl() +Studio.addControl() +Studio.selected = 1 +local first = Studio.page().controls[1] +check(Studio.moveControlOrder(1), "bring forward moves the control up") +eq(Studio.selected, 2, "and follows it with the selection") +check(Studio.page().controls[2] == first, "the control really moved") +check(not Studio.moveControlOrder(1), "the front control cannot go further") +check(Studio.moveControlOrder(-1), "send back moves it down again") +eq(Studio.selected, 1, "selection follows back") +check(not Studio.moveControlOrder(-1), "and the back control stays put") + +session() +Studio.addControl() +local ctl = Studio.selectedControl() +local canvas = Studio.canvas() +local startX, startY = ctl.x, ctl.y +Studio.nudge(1, 0) +near(ctl.x, startX + 1 / canvas.w, 1e-9, "an arrow moves one canvas pixel") +Studio.nudge(0, 1, true) +near(ctl.y, startY + 10 / canvas.h, 1e-9, "shift moves ten") +Studio.undo() +near(Studio.selectedControl().x, startX, 1e-9, "nudging is undoable") +Studio.selected = nil +check(not Studio.nudge(1, 0), "nothing selected, nothing nudged") + +check(Studio.NUDGES.up[2] == -1 and Studio.NUDGES.down[2] == 1, + "up is negative y on the canvas") +check(Studio.NUDGES.left[1] == -1 and Studio.NUDGES.right[1] == 1, + "and left is negative x") + +local off, line = Studio.snapOffset({ 100, 150, 200 }, { 152, 400 }, 4) +near(off, 2, 1e-9, "an edge within tolerance snaps to the guide") +near(line, 152, 1e-9, "and reports the line it snapped to") +off, line = Studio.snapOffset({ 100 }, { 400 }, 4) +eq(off, 0, "a line out of range does not move anything") +eq(line, nil, "and reports no guide") +off = Studio.snapOffset({ 100, 200 }, { 203, 101 }, 4) +near(off, 1, 1e-9, "the nearest candidate wins") + +session() +Studio.addControl() +local r = { x = 0, y = 0, w = 1000, h = 1000 } +local xs, ys = Studio.snapLines(Studio.page(), r, nil) +check(#xs >= 6 and #ys >= 6, + "snap lines cover the page box and every other control") +local skipped = select(1, Studio.snapLines(Studio.page(), r, 1)) +eq(#skipped, 3, "the dragged control is not a guide for itself") + +session() +Studio.addControl() +Studio.page().controls[1].x = 0.25 +Studio.addControl() +Studio.selected = 2 +local moving = Studio.selectedControl() +moving.x = 0.6 +local target = Studio.page().controls[1] +local bx, by, bw, bh = 0, 0, 0, 0 +local cx, cy, hw, hh = TouchSkin.controlGeometry(Studio.page(), moving, + r.w, r.h, r.x, r.y) +bx, by, bw, bh = cx - hw, cy - hh, hw * 2, hh * 2 +local tcx = select(1, TouchSkin.controlGeometry(Studio.page(), target, + r.w, r.h, r.x, r.y)) +Studio.drag = { kind = "control-move", mx = 0, my = 0, + bx = bx, by = by, bw = bw, bh = bh } +Studio.updateDrag((tcx - cx) + 3, 0, r) +local cx2 = select(1, TouchSkin.controlGeometry(Studio.page(), moving, + r.w, r.h, r.x, r.y)) +near(cx2, tcx, 1e-6, "a near miss snaps onto the other control's centre") +check(Studio.guides ~= nil and Studio.guides.x ~= nil, + "and a guide line is recorded for the canvas to draw") +Studio.drag = nil + +session() +Studio.addPage() +Studio.addPage() +eq(#Studio.skin.pages, 3, "three pages") +check(Studio.setPage(1), "setPage jumps to a page by index") +eq(Studio.pageIndex, 1, "and lands there") +check(not Studio.setPage(9), "an index past the end is refused") +Studio.nextPage() +eq(Studio.pageIndex, 2, "next page still cycles") +local name, detail = Studio.pageLabel(2) +eq(name, "page2", "the page list shows the page name") +check(detail:find("controls", 1, true) ~= nil, "and what is on it") + +check(Studio.renamePage("landscape"), "a page can be renamed") +eq(Studio.page().name, "landscape", "and keeps the new name") +check(not Studio.renamePage(" "), "an empty name is refused") +Studio.undo() +eq(Studio.page().name, "page2", "renaming is undoable") + +Studio.pageIndex = 2 +check(Studio.deletePage(2), "a page can be deleted") +eq(#Studio.skin.pages, 2, "and the skin loses it") +Studio.deletePage(1) +check(not Studio.deletePage(1), "the last page cannot be deleted") +check(Studio.statusErr, "and the studio says why") + +session() +check(not Studio.openImagePicker("idle"), "art needs a selected control") +Studio.addControl() +check(Studio.openImagePicker("idle"), "with one selected the grid opens") +eq(Studio.modal.kind, "image", "as the image modal") +eq(Studio.imageTarget, "idle", "aimed at the idle art") +check(Studio.openImagePicker("bezel"), "the bezel needs no selection") +eq(Studio.currentImagePath(), nil, "a new page has no bezel yet") +Studio.imageTarget = "idle" +Studio.selectedControl().imagePath = "img/a.png" +eq(Studio.currentImagePath(), "img/a.png", "the picker marks the current art") +Studio.chooseImage(nil) +eq(Studio.selectedControl().imagePath, nil, "picking (none) clears the art") +eq(Studio.modal, nil, "and closes the picker") +Studio.undo() +eq(Studio.selectedControl().imagePath, "img/a.png", "clearing art is undoable") + +session() +Studio.setStatus("boom", true) +check(Studio.statusErr, "an error status is flagged") +Studio.addControl() +eq(Studio.status, "boom", "a later edit does not wipe the error off the footer") +Studio.setStatus("fine") +Studio.addControl() +eq(Studio.status, nil, "an ordinary status still clears on the next edit") +Studio.setStatus("boom", true) +Studio.statusAt = -1000 +Studio.expireStatus() +eq(Studio.status, nil, "and an error clears itself after a few seconds") + +local ids = {} +for _, spec in ipairs(Studio.EXPORTS) do ids[spec.id] = spec.label end +check(ids.native and ids.retroarch and ids.delta, + "the export menu offers all three formats") + +session() +Studio.skinIdField = "uxtest" +local nativePath = Studio.exportAs("native") +check(nativePath ~= nil and nativePath:match("%.zip$") ~= nil, + "the native export writes a .zip") +local raPath = Studio.exportAs("retroarch") +check(raPath ~= nil and raPath:match("%.zip$") ~= nil, + "the RetroArch export writes a .zip") +local deltaPath = Studio.exportAs("delta") +check(deltaPath ~= nil and deltaPath:match("%.deltaskin$") ~= nil, + "the Delta export writes a .deltaskin") +check(love.filesystem.read(deltaPath) ~= nil, "and the archive is on disk") +eq(Studio.lastExport, deltaPath, "the last export is remembered for Show file") + +eq(Studio.skinFormat({ format = "retroarch" }), "RetroArch", + "a format badge reads in words") +eq(Studio.skinFormat({ format = "delta" }), "Delta", "Delta included") + +love.graphics.getDimensions = love.graphics.getDimensions + or function() return 1280, 720 end +session() +Studio.addControl() +for _, kind in ipairs({ "bind", "image", "open", "page", "export", "screen" }) do + Studio.openModal(kind) + check(pcall(Studio.draw), "the studio draws with the " .. kind .. " modal up") +end +Studio.closeModal() +check(Studio.openScreenMenu(), "Screen opens the canvas/screen modal") +eq(Studio.modal.kind, "screen", "as the screen modal") +Studio.closeModal() + +-- chrome stays inside the platform safe area (notch / home indicator) +local Kit = require("src.ui.kit.Kit") +local oldDims = love.graphics.getDimensions +local oldSafe = love.window.getSafeArea +love.graphics.getDimensions = function() return 400, 800 end +love.window.getSafeArea = function() return 12, 48, 376, 704 end +session() +Studio.mode = "library" +Studio.available = {} +Kit.audit = {} +check(pcall(Studio.draw), "My Skins draws inside an inset safe area") +local function insideSafe(rects, ox, oy, sw, sh, where) + for _, r in ipairs(rects or {}) do + check(r.x >= ox - 0.5, + where .. " control '" .. r.label .. "' is right of the left inset") + check(r.y >= oy - 0.5, + where .. " control '" .. r.label .. "' is below the notch") + check(r.x + r.w <= ox + sw + 0.5, + where .. " control '" .. r.label .. "' stays left of the right inset") + check(r.y + r.h <= oy + sh + 0.5, + where .. " control '" .. r.label .. "' stays above the home indicator") + end +end +insideSafe(Kit.audit, 12, 48, 376, 704, "library") +Studio.mode = "editor" +Studio.addControl() +Kit.audit = {} +check(pcall(Studio.draw), "the editor draws inside an inset safe area") +insideSafe(Kit.audit, 12, 48, 376, 704, "editor") +check(Studio.lastCanvas ~= nil, "the editor still has a mock device") +check(Studio.lastCanvas.y >= 48 - 0.5, "the mock device is below the notch") +check(Studio.lastCanvas.y + Studio.lastCanvas.h <= 48 + 704 + 0.5, + "and above the home indicator") +Kit.audit = nil +love.graphics.getDimensions = oldDims +love.window.getSafeArea = oldSafe +Studio.closeModal() +Studio.ask("sure?", function() end) +check(pcall(Studio.draw), "and with the confirm prompt up") +Studio.confirmNo() + +Studio.openModal("bind") +Studio.lastCanvas = { x = 0, y = 0, w = 100, h = 100 } +Studio.mousepressed(50, 50, 1) +eq(Studio.drag, nil, "a click under an open modal does not grab a control") +Studio.closeModal() + +T.finish("skin_studio_ux") diff --git a/tests/engine/touch_controls_pad_cursor_test.lua b/tests/engine/touch_controls_pad_cursor_test.lua new file mode 100644 index 000000000..4a0b47127 --- /dev/null +++ b/tests/engine/touch_controls_pad_cursor_test.lua @@ -0,0 +1,71 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Touch-controls editor pad / Joy-Con cursor (same class as save-editor soft-lock). +-- Opening Touch Controls parked the launcher cursor and dropped all gamepad +-- input; touch still worked. PadCursor + main.lua forwarding restore the +-- virtual pointer. +-- luajit tests/engine/touch_controls_pad_cursor_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 PadCursor = require("src.ui.PadCursor") +local GamepadMap = require("src.core.GamepadMap") + +PadCursor.reset() +PadCursor.gamepadaxis(nil, "leftx", 1) +PadCursor.update(0.05) +check(PadCursor.isActive(), "left stick activates pad cursor") +local x0 = select(1, PadCursor.pointer()) +PadCursor.update(0.05) +check(select(1, PadCursor.pointer()) > x0, "left stick moves cursor right") + +eq(PadCursor.gamepadpressed(nil, "a"), "a", "gamepad a → click") +eq(PadCursor.gamepadpressed(nil, "b"), "b", "gamepad b → close") +eq(PadCursor.gamepadpressed(nil, "leftshoulder"), "tab_prev", "L → size down") +eq(PadCursor.gamepadpressed(nil, "rightshoulder"), "tab_next", "R → size up") + +GamepadMap._setForceNXForTests(true) +eq(PadCursor.gamepadpressed(nil, "a"), "b", "NX SDL a (south) → close (GB b)") +eq(PadCursor.gamepadpressed(nil, "b"), "a", "NX SDL b (east) → click (GB a)") +GamepadMap._setForceNXForTests(false) + +PadCursor.yieldToPointer() +check(not PadCursor.isActive(), "yieldToPointer drops the virtual cursor") + +-- Native default editor keeps its actual pad implementation; it need not +-- replace the separately working save-editor cursor to expose these controls. +local Touch=require('src.core.TouchControls') +local Editor=require('src.ui.TouchControlsEditor') +local Save=require('src.core.SaveData') +local image=love.graphics.newImage +love.graphics.newImage=function(...) + local result=image(...) + function result:setFilter(min,mag)self.filter={min,mag}end + return result +end +love.system=love.system or {};love.system.getOS=function()return 'Linux'end +Editor.load();Editor.draw() +local z=Touch:layout().hotbar +PadCursor.reset() +-- D-pad motion positions the real virtual cursor; a touch must yield it. +Editor.gamepadaxis(nil,'leftx',1);Editor.update(.05) +check(PadCursor.isActive(),'native editor forwards actual gamepad axis') +Editor.touchpressed('finger',z.cx,z.cy) +check(not PadCursor.isActive(),'real editor touch yields virtual cursor') +eq(Editor.drag and Editor.drag.name,'hotbar','native editor captures toggle drag') +Editor.touchreleased('finger') +Editor.gamepadpressed(nil,'rightshoulder') +local scale=Touch:currentBucket().scale +check(scale>1,'native shoulder action changes actual layout scale') +Editor.gamepadpressed(nil,'leftshoulder') +eq(Touch:currentBucket().scale,1,'reverse shoulder restores original scale') +Editor.gamepadaxis(nil,'leftx',1);Editor.update(.05) +Editor.focus(false) +check(not PadCursor.isActive(),'focus loss releases actual virtual cursor') +eq(Editor.drag,nil,'focus loss cancels native editor drag') +Editor.unload() +T.finish('native default touch editor and shared pad cursor') diff --git a/tests/engine/ui_kit_pagination.lua b/tests/engine/ui_kit_pagination.lua new file mode 100644 index 000000000..a943f270c --- /dev/null +++ b/tests/engine/ui_kit_pagination.lua @@ -0,0 +1,129 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- The launcher's UI kit: pagination bounds, viewport-derived page size, and +-- the text safety rules. These replace the two FlexLove engine tests +-- (flexlove_wheel_scroll_dt0, launcher_save_slot_overlap_bug748), whose +-- subjects -- a scroll manager fed dt = 0 and an auto-height propagation bug +-- -- no longer exist: there is no scrolling and no layout engine. +-- +-- What DOES need guarding now is the property the whole performance claim +-- rests on: a list draws at most one page of rows regardless of how many +-- items it holds, and the page arithmetic never walks off either end. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.modkit") +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") + +-- ---------------------------------------------------------------- page math +-- pageBounds(page, total, perPage) -> first, last, clampedPage, pages +do + local first, last, page, pages = Kit.pageBounds(1, 38, 5) + T.eq(first, 1, "page 1 starts at item 1") + T.eq(last, 5, "page 1 ends at perPage") + T.eq(page, 1, "page 1 is in range") + T.eq(pages, 8, "38 items at 5 per page is 8 pages") + + first, last = Kit.pageBounds(8, 38, 5) + T.eq(first, 36, "the last page starts after the full ones") + T.eq(last, 38, "the last page stops at the item count, not at perPage") + + -- Out of range in both directions clamps rather than producing a window + -- that would index past the list (the launcher repages lists underneath + -- the user: a refresh can shrink an index from 38 mods to 2). + local _, _, low = Kit.pageBounds(0, 38, 5) + T.eq(low, 1, "page 0 clamps up to 1") + local f2, l2, high, p2 = Kit.pageBounds(99, 38, 5) + T.eq(high, 8, "a page past the end clamps to the last page") + T.eq(p2, 8, "the page count is unchanged by clamping") + T.check(l2 >= f2, "a clamped window is never inverted") + + -- An empty list still has one page, and draws no rows. + local ef, el, ep, epages = Kit.pageBounds(1, 0, 5) + T.eq(epages, 1, "an empty list still reports one page") + T.eq(ep, 1, "an empty list sits on page 1") + T.check(el < ef, "an empty page yields an empty row range") + + -- THE INVARIANT: no page ever yields more rows than perPage. + for total = 0, 40 do + for per = 1, 7 do + local _, _, _, np = Kit.pageBounds(1, total, per) + for p = 1, np do + local a, b = Kit.pageBounds(p, total, per) + T.check(b - a + 1 <= per, + ("page %d of %d (total %d) draws at most %d rows"):format(p, np, total, per)) + T.check(b <= total, "a page never runs past the item count") + end + end + end +end + +-- ------------------------------------------------------- viewport page size +-- rowsThatFit derives perPage from the real viewport, which is what lets a +-- tall window show more rows and a phone fewer with no scrolling either way. +do + -- n rows need n*rowH + (n-1)*gap pixels. + T.eq(Kit.rowsThatFit(200, 80, 20, 1, 20), 2, "200px fits two 80px rows + one gap") + T.eq(Kit.rowsThatFit(180, 80, 20, 1, 20), 2, "exactly two rows still fit") + T.eq(Kit.rowsThatFit(179, 80, 20, 1, 20), 1, "one pixel short drops to one row") + T.eq(Kit.rowsThatFit(0, 80, 20, 1, 20), 1, + "a collapsed viewport still shows one row rather than none") + T.eq(Kit.rowsThatFit(-500, 80, 20, 1, 20), 1, + "a negative budget cannot produce a negative page size") + T.eq(Kit.rowsThatFit(100000, 80, 20, 1, 12), 12, "the cap is honoured") +end + +-- ------------------------------------------------------------- text safety +-- Truncation must move whole codepoints. LOVE's Font:getWidth raises +-- "UTF-8 decoding error" on a string cut through a multi-byte sequence, and +-- the launcher lists mod names from third-party indexes -- this crashed the +-- first frame on a Japanese listing. +do + -- A font stub that REFUSES malformed UTF-8, the way LOVE's does. + local font = {} + function font:getWidth(s) + local i = 1 + while i <= #s do + local b = s:byte(i) + local n = (b < 0x80 and 1) or (b >= 0xF0 and 4) or (b >= 0xE0 and 3) + or (b >= 0xC0 and 2) or nil + if not n then error("UTF-8 decoding error: unexpected continuation", 0) end + for k = 1, n - 1 do + local c = s:byte(i + k) + if not c or c < 0x80 or c >= 0xC0 then + error("UTF-8 decoding error: Not enough space", 0) + end + end + i = i + n + end + -- 10px per codepoint, whatever its byte length + local count = 0 + i = 1 + while i <= #s do + local b = s:byte(i) + i = i + ((b < 0x80 and 1) or (b >= 0xF0 and 4) or (b >= 0xE0 and 3) or 2) + count = count + 1 + end + return count * 10 + end + + local jp = "ポケットモンスター" -- 9 codepoints, 27 bytes + local ok, cut = pcall(Theme.ellipsize, font, jp, 55) + T.check(ok, "ellipsize does not raise on multi-byte text: " .. tostring(cut)) + T.check(font:getWidth(cut) <= 55 + 1e-9, "the result fits the budget") + local ok2 = pcall(font.getWidth, font, cut) + T.check(ok2, "the truncated string is still valid UTF-8") + + local okL, cutL = pcall(Theme.ellipsizeLeft, font, jp, 55) + T.check(okL, "ellipsizeLeft does not raise on multi-byte text") + T.check(pcall(font.getWidth, font, cutL), + "the left-truncated string is still valid UTF-8") + + T.eq(Theme.ellipsize(font, jp, 0), "", + "a zero budget means nothing fits, not everything fits") + T.eq(Theme.ellipsize(font, "abc", 999), "abc", + "text that already fits is returned untouched") +end + +T.finish("ui_kit_pagination") From 2904a752374d00e86dff59b650e7b77f68437444 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:56:28 +0100 Subject: [PATCH 59/71] Install command capability probe at native module scope --- src/core/HostShell.lua | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index f13d248b1..bafd3f67a 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -146,18 +146,7 @@ end function HostShell.toLauncher() if launcherHook then return launcherHook() end - function HostShell.hasCommand(name) - if not HostShell.canSpawnProcess() or type(name)~='string' or #name>80 - or not name:match('^[%w][%w_.+-]*$') then return false end - local windows=love.system.getOS()=='Windows' - local pipe=HostShell.popen(windows and('where '..name..' 2>NUL')or("command -v '"..name.."' 2>/dev/null")) - if not pipe then return false end - local ok,value=pcall(pipe.read,pipe,'*a') - pcall(pipe.close,pipe) - return ok and type(value)=='string'and value:find('%S')~=nil -end - -return HostShell.restart() + return HostShell.restart() end function HostShell.restart() @@ -392,4 +381,15 @@ function HostShell.httpGet(url, userAgent, accept) return body end +function HostShell.hasCommand(name) + if not HostShell.canSpawnProcess() or type(name)~='string' or #name>80 + or not name:match('^[%w][%w_.+-]*$') then return false end + local windows=love.system.getOS()=='Windows' + local pipe=HostShell.popen(windows and('where '..name..' 2>NUL')or("command -v '"..name.."' 2>/dev/null")) + if not pipe then return false end + local ok,value=pcall(pipe.read,pipe,'*a') + pcall(pipe.close,pipe) + return ok and type(value)=='string'and value:find('%S')~=nil +end + return HostShell From 636fa7a02a8aa33850aac7d1919bcc2b9da77de5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:59:20 +0100 Subject: [PATCH 60/71] Close native picker pipes safely when host reads fail --- src/import/RomImporter.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index cb4f61612..40258dd9d 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2143,8 +2143,9 @@ local function commandOutput(command) releasePointerGrab() local pipe = HostShell.popen(command) if not pipe then return nil end - local result = pipe:read("*a") - pipe:close() + local ok, result = pcall(pipe.read, pipe, "*a") + pcall(pipe.close, pipe) + if not ok or type(result) ~= 'string' then return nil end result = trim(result) return result ~= "" and result or nil end From 0ef509b3856a4af4c84e6c76a8eef4d424ddb613 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:04:51 +0100 Subject: [PATCH 61/71] Verify native DS format bindings beyond the GB-only reference contract --- tests/engine/skin_format_import_test.lua | 5 ++++- tests/engine/skin_studio_native_host.lua | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/engine/skin_format_import_test.lua b/tests/engine/skin_format_import_test.lua index 4dd93ea15..169a7b972 100644 --- a/tests/engine/skin_format_import_test.lua +++ b/tests/engine/skin_format_import_test.lua @@ -212,7 +212,10 @@ 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") +-- The native host also owns DS controls. Unlike the GB-only reference, +-- importing a RetroPad Y must preserve that native button binding. +check(not ap.controls[14].decorative, "RetroPad Y remains a native input control") +eq(ap.controls[14].buttons[1], 'y', "RetroPad Y retains its native DS binding") eq(abxy.shape, "radial", "a radial area keeps its ellipse") eq(ap.controls[18].spec, "right", "analog_left degrades to a directional pad") diff --git a/tests/engine/skin_studio_native_host.lua b/tests/engine/skin_studio_native_host.lua index aeae68cbf..a17be6c24 100644 --- a/tests/engine/skin_studio_native_host.lua +++ b/tests/engine/skin_studio_native_host.lua @@ -85,6 +85,7 @@ T.eq(Studio.skin,previousSkin,'pending archive drop retains actual edited skin') Studio.confirmNo() T.eq(Studio.skin,previousSkin,'cancelling drop retains edits') -- The editor must preserve native DS controls, in addition to Gen1's binds. +local Version=require('src.core.GameVersion');Version.set('platinum') Studio.addControl();Studio.setBindSpec('x|y') local Input=require('src.core.Input');Input:init() local Touch=require('src.core.TouchControls');Touch:setPreview(false) @@ -95,6 +96,7 @@ T.check(Input:isDown('x') and Input:isDown('y'),'both DS buttons reach actual na Touch:touchreleased('ds-buttons',cx,cy) T.check(not Input:isDown('x') and not Input:isDown('y'),'DS bind release reaches native owner') Touch:setPreview(true) +Version.set('crystal') -- The studio's mock device occupies a rectangle inside the editor window. -- Its Test input must use that same rectangle, not the full window surface. Studio.setBindSpec('a');Studio.testing=true;Touch:setPreview(false) From 44e9fc3d3e041bfdad13e4e823f87010f07ae8d9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:10:01 +0100 Subject: [PATCH 62/71] Keep native DS skin declarations inert on Game Boy input owners --- src/core/TouchControls.lua | 25 +++++++++++++++++++----- tests/engine/skin_studio_native_host.lua | 10 ++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 5686888e2..d463e3cb7 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -75,11 +75,23 @@ local HOTBAR = { } local HOTBAR_SLOP = 1.4 +local function skinButtonAvailable(btn) + return (btn ~= 'x' and btn ~= 'y') or GameVersion.isGen4() +end + +local function skinControlAvailable(ctl) + if #ctl.keys > 0 or #ctl.hotkeys > 0 then return true end + for _,btn in ipairs(ctl.buttons)do + if skinButtonAvailable(btn) then return true end + end + return false +end + local function buttonAvailable(self, btn) -- X and Y are Platinum / HGSS buttons in play, but the launcher's layout -- editor (preview) shows them always: the layout it saves is shared by -- every game, and the Gen 4 ones are where a player wants to place them - if btn == 'x' or btn == 'y' then return self.preview or GameVersion.isGen4() end + if btn == 'x' or btn == 'y' then return self.preview or skinButtonAvailable(btn) end return self.img and self.img[btn] ~= nil end @@ -634,7 +646,8 @@ function TouchControls:hitTest(x, y) 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 + if not ctl.decorative and skinControlAvailable(ctl) + and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then return ctl.spec, ctl end end @@ -729,11 +742,13 @@ 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 _, btn in ipairs(ctl.buttons) do + if skinButtonAvailable(btn) and 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) + if skinButtonAvailable(btn) then pressBtn(self, btn) end end for _, key in ipairs(ctl.keys) do pressKey(self, key, true) end local switched = false @@ -757,7 +772,7 @@ function skinHitSet(self, x, y, prev) local set = nil for _, ctl in ipairs(page.controls) do local held = (prev and prev[ctl]) == true - if not ctl.decorative + if not ctl.decorative and skinControlAvailable(ctl) and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy, held) then set = set or {} set[ctl] = true diff --git a/tests/engine/skin_studio_native_host.lua b/tests/engine/skin_studio_native_host.lua index a17be6c24..52eceedd7 100644 --- a/tests/engine/skin_studio_native_host.lua +++ b/tests/engine/skin_studio_native_host.lua @@ -97,6 +97,16 @@ Touch:touchreleased('ds-buttons',cx,cy) T.check(not Input:isDown('x') and not Input:isDown('y'),'DS bind release reaches native owner') Touch:setPreview(true) Version.set('crystal') +Touch:setPreview(false) +T.eq(Touch:hitTest(cx,cy),nil,'DS-only art cannot steal a GB pointer gesture') +T.eq(Touch:touchpressed('gb-only',cx,cy),nil,'DS-only binding is inert in Crystal') +T.check(not Input:isDown('x') and not Input:isDown('y'),'unsupported buttons cannot reach GB input') +Studio.setBindSpec('x|a') +Touch:touchpressed('mixed',cx,cy) +T.check(Input:isDown('a') and not Input:isDown('x'),'mixed binding retains supported GB button only') +Touch:touchreleased('mixed',cx,cy) +T.check(not Input:isDown('a'),'mixed release retires actual GB input') +Touch:setPreview(true) -- The studio's mock device occupies a rectangle inside the editor window. -- Its Test input must use that same rectangle, not the full window surface. Studio.setBindSpec('a');Studio.testing=true;Touch:setPreview(false) From f42daf3d74311ccec0a1f8552cd332d942702b4b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:12:34 +0100 Subject: [PATCH 63/71] Scope studio Test capabilities and protect suspended game actions --- src/ui/SkinStudio.lua | 10 ++++++++++ tests/engine/skin_studio_native_host.lua | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index 7b614f874..09a45c739 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -526,6 +526,11 @@ function Studio.load(opts) TouchControls:init() TouchControls.active = true TouchControls.enabled = true + -- A mock-device test must never call a suspended game's reset/menu/speed + -- handler. Keep real control ownership/pressed art and report the action. + TouchControls:setHotkeyHandler(function(action,pressed) + if pressed then setStatus('Test action: '..action) end + end) TouchControls:setPreview(true) -- Play snaps pages from the window aspect. The studio uses its own -- Match canvas toggle against the mock device instead. #1503 @@ -791,6 +796,7 @@ function Studio.unload() TouchSkin.autoOrient = true TouchControls:setPreview(false) TouchControls:reset() + TouchControls:setHotkeyHandler(nil) Studio.skin = nil Studio.onClose = nil Studio.onPlay = nil @@ -2775,6 +2781,9 @@ end -- already relinquished that surface, so input must scope the same rectangle -- explicitly and restore its caller's surface afterwards. local function testPad(method,x,y) + local Version=require('src.core.GameVersion') + local previousVersion=Version.get() + if Studio.version and Version.VERSIONS[Studio.version] then Version.set(Studio.version) end local previous=TouchSkin.surfaceRect local r=Studio.lastCanvas if r then TouchSkin.setSurface(r.x,r.y,r.w,r.h) end @@ -2782,6 +2791,7 @@ local function testPad(method,x,y) if previous then TouchSkin.setSurface(previous.x,previous.y,previous.w,previous.h) else TouchSkin.setSurface(nil) end + Version.set(previousVersion) if not ok then error(result,0) end return result end diff --git a/tests/engine/skin_studio_native_host.lua b/tests/engine/skin_studio_native_host.lua index 52eceedd7..17f483c67 100644 --- a/tests/engine/skin_studio_native_host.lua +++ b/tests/engine/skin_studio_native_host.lua @@ -44,6 +44,8 @@ local host=setmetatable({tab='crystal',ready={crystal=true},_padCursor={x=90,y=8 Importer.new=function(_,opts)options=opts;return host end open(false);Importer.new=oldNew T.check(type(options.onEditTouchSkins)=='function','native launcher actually supplies studio action') +local oldGameActions=0 +require('src.core.TouchControls'):setHotkeyHandler(function()oldGameActions=oldGameActions+1 end) options.onEditTouchSkins() local Studio=require('src.ui.SkinStudio') local Skin=require('src.core.TouchSkin') @@ -118,6 +120,17 @@ T.check(Input:isDown('a'),'Test input uses actual mock-device coordinates') T.eq(Skin.surfaceRect,nil,'Test input restores outer skin surface') Studio.mousereleased(cx,cy,1) T.check(not Input:isDown('a'),'mock-device release clears native button') +Studio.setBindSpec('reset') +Studio.mousepressed(cx,cy,1);Studio.mousereleased(cx,cy,1) +T.eq(oldGameActions,0,'Test cannot reset or change a suspended actual game') +T.check(Studio.status:find('soft_reset',1,true)~=nil,'Test reports the real parsed hotkey action') +Studio.version='platinum';Studio.setBindSpec('x|y') +Studio.mousepressed(cx,cy,1) +T.check(Input:isDown('x') and Input:isDown('y'),'mock-device input uses selected DS cartridge capabilities') +T.eq(Version.get(),'crystal','mock-device input restores outer cartridge context') +Studio.mousereleased(cx,cy,1) +T.check(not Input:isDown('x') and not Input:isDown('y'),'scoped DS release retires actual inputs') +Studio.version='crystal' Studio.testing=false;Touch:setPreview(true) local calls=0 local mouse=Studio.mousepressed From 91305d513073117e8141a61e786268776a500b2b 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 64/71] 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 40258dd9d..6b6c87f54 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -5672,7 +5672,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 @@ -6207,7 +6213,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 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 65/71] 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 c23c6627384836f6cefb0024d9adcfd1f7350d38 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:22:45 +0100 Subject: [PATCH 66/71] Preserve selected format when Windows stages non-ASCII image paths --- src/core/FilePicker.lua | 2 +- tests/engine/native_file_picker.lua | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/FilePicker.lua b/src/core/FilePicker.lua index cca832874..94e61a874 100644 --- a/src/core/FilePicker.lua +++ b/src/core/FilePicker.lua @@ -56,7 +56,7 @@ function M.commands(prompt,kind) '$d.Filter='..windows(filter)..';',"if($d.ShowDialog() -eq 'OK'){$p=$d.FileName;$ascii=$true;", 'foreach($c in $p.ToCharArray()){if([int]$c -lt 32 -or [int]$c -gt 126){$ascii=$false}};', '[Console]::OutputEncoding=[Text.Encoding]::UTF8;if($ascii){[Console]::Write($p)}else{', - "$t=Join-Path $env:TEMP ('pokeport_base_'+[Guid]::NewGuid().ToString('N')+'.bin');", + "$t=Join-Path $env:TEMP ('pokeport_base_'+[Guid]::NewGuid().ToString('N')+[IO.Path]::GetExtension($p));", 'try{Copy-Item -LiteralPath $p -Destination $t -ErrorAction Stop;[Console]::Write($t)}catch{}}}'}) local command='powershell -NoProfile -STA -Command "'..script..'"' if #command>7000 then return end diff --git a/tests/engine/native_file_picker.lua b/tests/engine/native_file_picker.lua index 010bf08a2..c6df0caaa 100644 --- a/tests/engine/native_file_picker.lua +++ b/tests/engine/native_file_picker.lua @@ -30,6 +30,8 @@ for _,os in ipairs{'OS X','Windows','Linux'}do T.check(cmds[1]:find('[Text.Encoding]::UTF8.GetString([byte[]]@(',1,true)~=nil) T.check(cmds[1]:find('if($ascii){[Console]::Write($p)}else{',1,true)~=nil,'large ASCII file fast path') T.check(cmds[1]:find('[Guid]::NewGuid()',1,true)~=nil,'unique non-ASCII staging') + T.check(cmds[1]:find('[IO.Path]::GetExtension($p)',1,true)~=nil, + 'non-ASCII staging preserves image/archive extension for the actual importer') T.check(cmds[1]:find('-ErrorAction Stop',1,true)~=nil,'failed staging cannot return stale data') else T.check(cmds[1]:find('zenity ',1,true)==1)end end From ec4b962597b39ac89c3c893a91252870a8b0737f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:29:31 +0100 Subject: [PATCH 67/71] Document offered native studio and real provider qualification --- docs/qa/native-skin-studio.md | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 docs/qa/native-skin-studio.md diff --git a/docs/qa/native-skin-studio.md b/docs/qa/native-skin-studio.md new file mode 100644 index 000000000..d2087857c --- /dev/null +++ b/docs/qa/native-skin-studio.md @@ -0,0 +1,48 @@ +# Offered native skin studio + +Depends on the offered viewport/runtime/hotbar stacks (#124, #383, #386). +This branch adapts the actual older native launcher and its flat settings +owners; it does not import a replacement Gen1 launcher. The library/editor, +widget toolkit and shared cursor are complete implementations. All62 icons +are independently drawn native vectors, without excluded Gen1 artwork. + +The native host routes pointer/text/keyboard/wheel/focus/device events, +suspends and resumes its own launcher, and defers Play until update. The +embedded Test device scopes the same rectangle as drawing and the selected +cartridge context, restoring both afterwards. Preview reset/menu/speed actions +report their parsed action and cannot call a suspended game's handlers. +Native Gen4 X/Y declarations work through real Input; DS-only contacts stay +inert/click-through on Crystal, and mixed bindings retain supported GB input. + +The shared native FilePicker provider now has real capability probes, +validated/quoted commands, one-backend cancellation, pointer-release ownership +and protected binary reads. Older dialog pipe reads could raise instead of +returning failure; they now close the pipe and return no selected file. +Windows non-ASCII staging preserves the selected extension rather than .bin. +This is a command contract, not complete interactive Windows/Unicode IO proof. +Required original-project and contribution credits remain in README and the +native launcher's measured/wrapped footer alongside existing authors. + +Evidence: + +- Actual offered native main/host callback suite50, studio model137, image + import35, UX211, library reach118, default cursor/editor16, transitions36, + pagination4472, spinner5 and updated format243 contracts pass. Native + hotbar271 and skin65 regressions pass. Picker91 and import-error28 pass. +- Real isolated LOVE12 on Apple M3, exact636fa7a0: Metal and OpenGL each + pass18 studio GUI/Test framebuffers, nine owned on-disk skins, actual PNG + save/reload, undo/redo, native ZIP export/drop/import and persisted selection, + plus all62 native vector icons. Later changes add explicit generation gates, + scoped preview actions and command/format contracts, verified separately. +- Native host and picker91/skin65 focused tests pass on c23c6627. The complete + available official scripts/test.sh run on that offered tree is in progress. + There is no current upstream-main mergeability, full Gen1 GUI pixel identity, + OS-window/mobile/FPS/full-mod or release qualification claim. + +Provenance: identified studio/cursor/widget code and adapted tests originate +in Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC, under the +preserved GPLv3/Section7 terms in licenses/GEN1RECOMP-TOUCH-SKINS.md. +Native integration, FilePicker provider and vector artwork contributions by +Roxas2712 are expressly offered under LICENSE-ROXAS2712.md, subject to +existing grants and other authors' retained rights. Mods/player profiles +are not modified. Full parity and release acceptance remain open. From 7af45d12c65e6c948d8d4f066c09cf0d4a97fce8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:44:54 +0100 Subject: [PATCH 68/71] Record complete offered-stack test failures without baseline assumptions --- docs/qa/native-skin-studio.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/qa/native-skin-studio.md b/docs/qa/native-skin-studio.md index d2087857c..c86d685c8 100644 --- a/docs/qa/native-skin-studio.md +++ b/docs/qa/native-skin-studio.md @@ -35,7 +35,9 @@ Evidence: plus all62 native vector icons. Later changes add explicit generation gates, scoped preview actions and command/format contracts, verified separately. - Native host and picker91/skin65 focused tests pass on c23c6627. The complete - available official scripts/test.sh run on that offered tree is in progress. + available official c23c6627 run is terminal:117/127 engine suites and3/3 SDK + pass. The ten failing engine suites need trace/baseline attribution; this + is not a green upstream-stack acceptance. There is no current upstream-main mergeability, full Gen1 GUI pixel identity, OS-window/mobile/FPS/full-mod or release qualification claim. From b9a68c81bc9001ad236640b81dbd6bff16936c33 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:56:45 +0100 Subject: [PATCH 69/71] Read original Windows skin paths through the native Unicode API --- .github/workflows/native-file-picker.yml | 55 ++++++++++++++++ docs/qa/native-skin-studio.md | 16 +++++ src/core/FilePicker.lua | 23 ++++--- src/core/NativeFile.lua | 84 ++++++++++++++++++++++++ src/import/RomImporter.lua | 2 +- tests/engine/native_file_io.lua | 28 ++++++++ tests/engine/native_file_picker.lua | 13 ++++ tests/native_file_io/conf.lua | 7 ++ tests/native_file_io/main.lua | 49 ++++++++++++++ 9 files changed, 267 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/native-file-picker.yml create mode 100644 src/core/NativeFile.lua create mode 100644 tests/engine/native_file_io.lua create mode 100644 tests/native_file_io/conf.lua create mode 100644 tests/native_file_io/main.lua diff --git a/.github/workflows/native-file-picker.yml b/.github/workflows/native-file-picker.yml new file mode 100644 index 000000000..107ddf332 --- /dev/null +++ b/.github/workflows/native-file-picker.yml @@ -0,0 +1,55 @@ +name: Native desktop file selection +on: + pull_request: + paths: + - 'src/core/FilePicker.lua' + - 'src/core/NativeFile.lua' + - 'src/core/HostShell.lua' + - 'src/import/RomImporter.lua' + - 'tests/engine/native_file_picker.lua' + - 'tests/engine/native_file_io.lua' + - 'tests/native_file_io/**' + - 'tests/*picker*' + - 'tests/rom_importer_android_mod_pick_test.lua' + - '.github/workflows/native-file-picker.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: native-file-picker-${{ github.ref }} + cancel-in-progress: true +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - run: sudo apt-get update && sudo apt-get install -y luajit + - name: Model desktop commands without opening dialogs + run: | + luajit tests/engine/native_file_picker.lua + luajit tests/engine/native_file_io.lua + - name: Preserve native pointer release and mobile fallback + run: | + luajit tests/parity_picker_pointer_grab.lua + luajit tests/rom_importer_no_picker_test.lua + luajit tests/rom_importer_android_mod_pick_test.lua + windows-unicode-files: + runs-on: windows-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + - name: Read real Unicode files through the native wide API + shell: python + run: | + import os, pathlib, subprocess, urllib.request, zipfile + target = pathlib.Path(os.environ['RUNNER_TEMP']) / 'love-native-file-io' + target.mkdir() + archive = target / 'runtime.zip' + urllib.request.urlretrieve('https://github.com/love2d/love/releases/download/11.5/love-11.5-win64.zip', archive) + with zipfile.ZipFile(archive) as z: z.extractall(target) + executable = next(target.rglob('lovec.exe')) + subprocess.run([str(executable), 'tests/native_file_io', str(pathlib.Path.cwd())], check=True, timeout=90) diff --git a/docs/qa/native-skin-studio.md b/docs/qa/native-skin-studio.md index c86d685c8..0bc11ff28 100644 --- a/docs/qa/native-skin-studio.md +++ b/docs/qa/native-skin-studio.md @@ -48,3 +48,19 @@ Native integration, FilePicker provider and vector artwork contributions by Roxas2712 are expressly offered under LICENSE-ROXAS2712.md, subject to existing grants and other authors' retained rights. Mods/player profiles are not modified. Full parity and release acceptance remain open. + +## Windows original-path file reads + +SkinStudio's `FilePicker.open/read` now keeps the original UTF-8 filename and +uses `NativeFile`'s readonly Win32 wide-character reader. It does not copy the +image into `%TEMP%`, whose user-profile path can itself contain non-ASCII +characters. Existing `chooseFileByExt` callers retain their staging contract +unless they explicitly select the native UTF-8 reader. + +The Windows workflow runs LÖVE/LuaJIT against real binary files under a directory +containing accented, CJK and non-BMP characters, checks exact multi-block bytes +and basename preservation, then replaces/removes them to prove handle release. +It also checks missing/empty files and invalid UTF-8. The portable engine suite covers exception +and close handling. These are file-IO checks, not a Windows GPU or full-mod +qualification; inspect the exact-head workflow result before claiming Windows +acceptance. diff --git a/src/core/FilePicker.lua b/src/core/FilePicker.lua index 94e61a874..6f634e77e 100644 --- a/src/core/FilePicker.lua +++ b/src/core/FilePicker.lua @@ -39,7 +39,7 @@ local function windows(value) local bytes={};for i=1,#value do bytes[i]=tostring(value:byte(i))end return '([Text.Encoding]::UTF8.GetString([byte[]]@('..table.concat(bytes,',')..')))' end -function M.commands(prompt,kind) +function M.commands(prompt,kind,nativeUTF8) local k=kindOf(kind) if not k or not promptOk(prompt)then return end local os=backend();if not os then return end @@ -51,6 +51,17 @@ function M.commands(prompt,kind) elseif os=='Windows'then local patterns=table.concat(globs,';') local filter=k.label..' ('..patterns..')|'..patterns..'|All files (*.*)|*.*' + -- The skin editor reads through NativeFile's wide API. Other existing + -- chooseFileByExt consumers still expect an ANSI-readable staging path. + if nativeUTF8 then + local command='powershell -NoProfile -STA -Command "'..table.concat({ + 'Add-Type -AssemblyName System.Windows.Forms;', + '$d=New-Object System.Windows.Forms.OpenFileDialog;','$d.Title='..windows(prompt)..';', + '$d.Filter='..windows(filter)..';', + "if($d.ShowDialog() -eq 'OK'){[Console]::OutputEncoding=[Text.Encoding]::UTF8;[Console]::Write($d.FileName)}"})..'"' + if #command>7000 then return end + return{command} + end local script=table.concat({'Add-Type -AssemblyName System.Windows.Forms;', '$d=New-Object System.Windows.Forms.OpenFileDialog;','$d.Title='..windows(prompt)..';', '$d.Filter='..windows(filter)..';',"if($d.ShowDialog() -eq 'OK'){$p=$d.FileName;$ascii=$true;", @@ -69,16 +80,10 @@ function M.commands(prompt,kind) end function M.open(prompt,kind) local k=kindOf(kind);if not k or not promptOk(prompt)then return end - return require('src.import.RomImporter').chooseFileByExt(k.exts,nil,{title=prompt,label=k.label}) + return require('src.import.RomImporter').chooseFileByExt(k.exts,nil,{title=prompt,label=k.label,nativeUTF8=true}) end function M.basename(path)return tostring(path or''):match('([^/\\]+)$')or tostring(path or'')end function M.read(path) - if type(path)~='string'or path:find('%z')then return nil,'invalid native file path'end - local opened,file,why=pcall(io.open,path,'rb') - if not opened or not file then return nil,opened and why or file end - local ok,bytes,err=pcall(file.read,file,'*a');pcall(file.close,file) - if not ok then return nil,bytes end - if not bytes or bytes==''then return nil,err or 'empty file'end - return bytes + return require('src.core.NativeFile').read(path) end return M diff --git a/src/core/NativeFile.lua b/src/core/NativeFile.lua new file mode 100644 index 000000000..90da72509 --- /dev/null +++ b/src/core/NativeFile.lua @@ -0,0 +1,84 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Read a user-selected host file. Windows LuaJIT's io.open uses the ANSI +-- codepage; the native wide API keeps UTF-8 paths, including the user's TEMP +-- directory, independent of that codepage. This never stages or writes data. +local NativeFile = {} +local windowsAPI + +local function windowsRead(path) + local ffi = require('ffi') + if ffi.os ~= 'Windows' then return nil, 'native Windows reader unavailable' end + if not windowsAPI then + ffi.cdef[[ + int __stdcall MultiByteToWideChar(unsigned int, unsigned long, const char *, int, uint16_t *, int); + void * __stdcall CreateFileW(const uint16_t *, unsigned long, unsigned long, void *, unsigned long, unsigned long, void *); + int __stdcall GetFileSizeEx(void *, int64_t *); + int __stdcall ReadFile(void *, void *, unsigned long, unsigned long *, void *); + int __stdcall CloseHandle(void *); + unsigned long __stdcall GetLastError(void); + ]] + windowsAPI = ffi.load('kernel32') + end + local api = windowsAPI + local n = api.MultiByteToWideChar(65001, 8, path, #path, nil, 0) + if n == 0 then return nil, 'invalid UTF-8 file path' end + local wide = ffi.new('uint16_t[?]', n + 1) + if api.MultiByteToWideChar(65001, 8, path, #path, wide, n) == 0 then + return nil, 'cannot convert UTF-8 file path' + end + -- OPEN_EXISTING, GENERIC_READ, FILE_SHARE_READ: never create/truncate the + -- chosen file and do not accept a concurrent writer changing its contents. + local handle = api.CreateFileW(wide, 0x80000000, 1, nil, 3, 0x08000000, nil) + if handle == ffi.cast('void *', -1) then + return nil, 'cannot open file (Windows error '..tonumber(api.GetLastError())..')' + end + handle = ffi.gc(handle, api.CloseHandle) + local function read() + local size = ffi.new('int64_t[1]') + if api.GetFileSizeEx(handle, size) == 0 then + return nil, 'cannot measure file (Windows error '..tonumber(api.GetLastError())..')' + end + local length = tonumber(size[0]) + if length == 0 then return nil, 'empty file' end + if length < 0 or length > 2147483647 then return nil, 'file too large for a Lua string' end + local buffer, count = ffi.new('uint8_t[65536]'), ffi.new('unsigned long[1]') + local chunks, remaining = {}, length + while remaining > 0 do + local wanted = math.min(remaining, 65536) + if api.ReadFile(handle, buffer, wanted, count, nil) == 0 then + return nil, 'cannot read file (Windows error '..tonumber(api.GetLastError())..')' + end + local got = tonumber(count[0]) + if got == 0 then return nil, 'file ended before its reported size' end + chunks[#chunks + 1] = ffi.string(buffer, got) + remaining = remaining - got + end + return table.concat(chunks) + end + local ok, bytes, why = pcall(read) + ffi.gc(handle, nil) + api.CloseHandle(handle) + if not ok then return nil, bytes end + return bytes, why +end + +function NativeFile.read(path) + if type(path) ~= 'string' or path == '' or path:find('%z') then + return nil, 'invalid native file path' + end + local osName = love and love.system and love.system.getOS and love.system.getOS() + if osName == 'Windows' then + local ok, bytes, why = pcall(windowsRead, path) + if not ok then return nil, bytes end + return bytes, why + end + local opened, file, why = pcall(io.open, path, 'rb') + if not opened or not file then return nil, opened and why or file end + local ok, bytes, err = pcall(file.read, file, '*a') + pcall(file.close, file) + if not ok then return nil, bytes end + if not bytes or bytes == '' then return nil, err or 'empty file' end + return bytes +end + +return NativeFile diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 6b6c87f54..a39b422bb 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2721,7 +2721,7 @@ end local function chooseFileByExt(exts, promptName, opts) opts = opts or {} local prompt = opts.title or Strings("Choose your %s", promptName or "file") - local commands = require('src.core.FilePicker').commands(prompt, {label=opts.label or 'Base file',exts=exts}) + local commands = require('src.core.FilePicker').commands(prompt, {label=opts.label or 'Base file',exts=exts}, opts.nativeUTF8) for _,command in ipairs(commands or {}) do -- One selected backend: cancelling its dialog must not open another one. return commandOutput(command) diff --git a/tests/engine/native_file_io.lua b/tests/engine/native_file_io.lua new file mode 100644 index 000000000..5035af273 --- /dev/null +++ b/tests/engine/native_file_io.lua @@ -0,0 +1,28 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Portable error/handle contract. The Windows CI driver separately exercises +-- the actual wide APIs against files written by LÖVE in a Unicode directory. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local Native=require('src.core.NativeFile') +local oldLove,oldOpen=love,io.open +love={system={getOS=function()return 'Linux'end}} +for _,bad in ipairs{false,42,'','a\0b'}do + local bytes,why=Native.read(bad);T.eq(bytes,nil);T.check(type(why)=='string') +end +local closed=0 +io.open=function(path,mode) + T.eq(path,'owned fixture');T.eq(mode,'rb') + return{read=function()return '\0PNG\255'end,close=function()closed=closed+1 end} +end +T.eq(Native.read('owned fixture'),'\0PNG\255');T.eq(closed,1) +io.open=function()return nil,'owned open failure'end +local bytes,why=Native.read('owned fixture');T.eq(bytes,nil);T.eq(why,'owned open failure') +io.open=function()error('owned open exception')end +bytes,why=Native.read('owned fixture');T.eq(bytes,nil);T.check(why:find('owned open exception',1,true)~=nil) +for _,read in ipairs{function()return ''end,function()error('owned read exception')end,function()return nil,'owned read failure'end}do + io.open=function()return{read=read,close=function()closed=closed+1 end}end + bytes,why=Native.read('owned fixture');T.eq(bytes,nil);T.check(type(why)=='string') +end +T.eq(closed,4,'all opened handles are closed, including read failures') +io.open, love=oldOpen,oldLove +T.finish('native readonly file bytes and error cleanup') diff --git a/tests/engine/native_file_picker.lua b/tests/engine/native_file_picker.lua index c6df0caaa..46d65c607 100644 --- a/tests/engine/native_file_picker.lua +++ b/tests/engine/native_file_picker.lua @@ -33,6 +33,10 @@ for _,os in ipairs{'OS X','Windows','Linux'}do T.check(cmds[1]:find('[IO.Path]::GetExtension($p)',1,true)~=nil, 'non-ASCII staging preserves image/archive extension for the actual importer') T.check(cmds[1]:find('-ErrorAction Stop',1,true)~=nil,'failed staging cannot return stale data') + local direct=assert(F.commands(prompt,kind,true))[1] + T.check(direct:find('[Console]::Write($d.FileName)',1,true)~=nil,'wide-reader caller keeps original selected name') + T.eq(direct:find('Copy-Item',1,true),nil,'wide-reader caller never copies into Unicode TEMP') + T.eq(direct:find('do-not-run',1,true),nil,'direct picker also encodes untrusted prompt bytes') else T.check(cmds[1]:find('zenity ',1,true)==1)end end osName='Linux';available.zenity=false;T.check(F.commands('QA',kind)[1]:find('kdialog ',1,true)==1) @@ -56,6 +60,15 @@ T.eq(F.open('QA cancel',kind),nil);T.eq(launches,2,'cancel cannot launch another local beforeClose=closed Host.popen=function()return{read=function()error('owned dialog read failure')end,close=function()closed=closed+1 end}end T.eq(F.open('QA failure',kind),nil);T.eq(closed,beforeClose+1) +osName='Windows' +Host.popen=function(command) + T.check(command:find('[Console]::Write($d.FileName)',1,true)~=nil,'public FilePicker.open selects wide-readable original path') + T.eq(command:find('Copy-Item',1,true),nil) + return{read=function()return 'C:\\用户\\Pokémon 🎮.png'end,close=function()end} +end +local original=F.open('Pick art',F.IMAGE) +T.eq(original,'C:\\用户\\Pokémon 🎮.png');T.eq(F.basename(original),'Pokémon 🎮.png') +osName='Linux' love.mouse,love.event,love.timer=mouse,event,timer local oldOpen=io.open;local reads,closes=0,0 io.open=function(path,mode) diff --git a/tests/native_file_io/conf.lua b/tests/native_file_io/conf.lua new file mode 100644 index 000000000..3a9cbdf10 --- /dev/null +++ b/tests/native_file_io/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity='gen2-native-file-io-qa' + t.window=false + t.modules.graphics=false + t.modules.audio=false + t.modules.joystick=false +end diff --git a/tests/native_file_io/main.lua b/tests/native_file_io/main.lua new file mode 100644 index 000000000..671c34b76 --- /dev/null +++ b/tests/native_file_io/main.lua @@ -0,0 +1,49 @@ +-- Actual Windows/LuaJIT/LÖVE file test, launched with the repository root as +-- the one argument. No ROM, mod, player profile, dialog or GPU is used. +function love.load(args) + local root=assert(args[1],'repository root required'):gsub('\\','/') + package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path + local Native=require('src.core.NativeFile') + local Picker=require('src.core.FilePicker') + local fs=love.filesystem + local checks=0 + local function eq(a,b,label) + checks=checks+1;assert(a==b,(label or 'values differ')..': '..tostring(a)..' ~= '..tostring(b)) + end + local dir='owned-'..tostring(os.time())..'-'..love.math.random(1,999999)..'-用户-é-🎮' + local files={} + local oldOpen=io.open + local ok,why=xpcall(function() + eq(love.system.getOS(),'Windows','real Windows host') + eq(require('ffi').os,'Windows','real Windows FFI') + assert(fs.createDirectory(dir)) + local expected=string.rep('\0PNG\255\r\n',20000)..'end' + for _,name in ipairs{'Pokémon é.png','精灵 🎮.spritepack','ascii.png'}do + local rel=dir..'/'..name;files[#files+1]=rel + assert(fs.write(rel,expected)) + local path=fs.getSaveDirectory()..'/'..rel + io.open=function()error('ANSI io.open must not be used on Windows')end + eq(Native.read(path),expected,'wide read exact multi-block binary') + eq(Picker.read(path),expected,'public picker uses native wide reader') + eq(Picker.basename(path),name,'original name retained') + io.open=oldOpen + assert(fs.write(rel,'replacement')) + eq(Picker.read(path),'replacement','readonly handle released before replacement') + eq(fs.remove(rel),true,'read handles closed before removal') + end + -- These refusals still close any opened handle and cannot alias a path + -- through the current Windows ANSI codepage. + local missing=fs.getSaveDirectory()..'/'..dir..'/missing.png' + local bytes,err=Native.read(missing);eq(bytes,nil,'missing path');eq(type(err),'string') + bytes,err=Native.read(missing..'\255');eq(bytes,nil,'invalid UTF-8 refused');eq(err,'invalid UTF-8 file path') + local empty=dir..'/empty.png';files[#files+1]=empty;assert(fs.write(empty,'')) + bytes,err=Native.read(fs.getSaveDirectory()..'/'..empty);eq(bytes,nil);eq(err,'empty file') + eq(fs.remove(empty),true,'empty-file handle closed') + end,debug.traceback) + io.open=oldOpen + for _,rel in ipairs(files)do fs.remove(rel)end + fs.remove(dir) + if ok then print('NATIVE_FILE_IO_WINDOWS '..checks..' checks passed') + else io.stderr:write(tostring(why)..'\n')end + love.event.quit(ok and 0 or 1) +end From fb1519eff1232b9aec07251c6d852cab7f104f53 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:06:55 +0100 Subject: [PATCH 70/71] Qualify the offered native stack against real engine test contracts --- docs/qa/native-stack-test-baseline.md | 38 ++++++++ tests/engine/gate_fingerprint.lua | 38 +++++++- tests/engine/public_battle_hud_hook.lua | 31 +++++++ tests/engine/public_field_events.lua | 82 +++++++++++++++++ tests/engine/public_intro_events.lua | 43 +++++++++ tests/engine/quit_thread_shutdown.lua | 87 ++++++++++++++++--- tests/engine/rebind_capture_bug510.lua | 41 ++++++--- tests/engine/rebind_swap_clear_bug589.lua | 42 +++++---- tests/engine/soft_reset_bug563.lua | 28 ++++-- tests/engine/tool_mod_hooks.lua | 23 +++-- tests/engine/update_check_tests.lua | 21 +++-- tests/engine/update_tests.lua | 16 ++-- tests/engine/wide_battle_layout.lua | 45 ++++++++-- tests/goldens/fixture_fingerprint.md | 36 ++++++++ tests/goldens/fixture_fingerprint.txt | 2 +- .../goldens/fixture_fingerprint_sections.txt | 7 ++ 16 files changed, 502 insertions(+), 78 deletions(-) create mode 100644 docs/qa/native-stack-test-baseline.md create mode 100644 tests/engine/public_battle_hud_hook.lua create mode 100644 tests/engine/public_field_events.lua create mode 100644 tests/engine/public_intro_events.lua create mode 100644 tests/goldens/fixture_fingerprint.md create mode 100644 tests/goldens/fixture_fingerprint_sections.txt diff --git a/docs/qa/native-stack-test-baseline.md b/docs/qa/native-stack-test-baseline.md new file mode 100644 index 000000000..684c3c2fb --- /dev/null +++ b/docs/qa/native-stack-test-baseline.md @@ -0,0 +1,38 @@ +# Native stack test baseline + +The exact viewport prerequisite `ce4a825a961d35df2e6748b22632843f77d5c671` +passes 100/110 engine suites and 3/3 SDK suites under the official +`scripts/test.sh` command. The offered studio source `c23c6627` passes +117/127 engine suites and 3/3 SDK suites. Both runs fail the same ten named +suites, before and after the runtime/hotbar/studio additions. Both runs omit +the ROM-dependent content/link tier when no generated Red dataset is present. + +The repairs retain the actual production owners: + +- `rebind_capture_bug510` and `rebind_swap_clear_bug589` locate rows by their + public action metadata, retaining release-to-commit, swap/clear/cancel and + uniqueness assertions across the native twelve-action map. +- `soft_reset_bug563` and `tool_mod_hooks` use actual `Game` method lookup, + including the profiling `_step` body, instead of incomplete plain tables. +- `quit_thread_shutdown` executes the real `love.run` function to prove veto, + Android-only exit and return ordering. A pattern stopping at the first + nested `end` could not establish any of those behaviors. Existing native + shutdown assertions remain appropriate to this older host. +- `update_check_tests` and `update_tests` use this engine's actual + `Gen2Recomped` payload names and upstream release URL. A negative case + explicitly rejects a foreign Gen1 payload; the updater is not changed. +- `wide_battle_layout` tests the authored OAM anchor positions, interpolation, + wobble, empty frames and bounded overshoot, rather than expecting two + unrelated frames to coincide with anchors they do not occupy. +- `gate_meta_coverage` gains real public-API/owner tests for Birch enter/finish, + bulk block replacement, world ticks, egg hatch completion and native HUD + ownership. No coverage debt is added to silence the gate. +- `gate_fingerprint` retains mutation/determinism checks and uses the + independently reviewed `41e69caf4afa35ab` fixture pin from the prior native + registry audit, with per-section pins and four Sinnoh-registration removal + checks. No serializer, registry, hash algorithm or ROM golden is altered. + See `tests/goldens/fixture_fingerprint.md` for the audited input change. + +The changed stack still needs its own exact-source full test result. A passing +test-fixture repair is not complete Gen1 behavioral parity, unchanged-mod +gameplay, current upstream-main integration or packaged-release acceptance. diff --git a/tests/engine/gate_fingerprint.lua b/tests/engine/gate_fingerprint.lua index 731996016..85fb65a72 100644 --- a/tests/engine/gate_fingerprint.lua +++ b/tests/engine/gate_fingerprint.lua @@ -9,8 +9,8 @@ -- itself, and would silently make two builds of the same engine refuse to -- link. Here it flips one hex string and fails. -- --- Regenerate deliberately with scripts/test.sh --bless after recording the --- intended parity change; never automatically. +-- Regenerate deliberately after auditing each changed surface section; see +-- tests/goldens/fixture_fingerprint.md. Never update expected values automatically. package.path = "./?.lua;./?/init.lua;" .. package.path @@ -37,6 +37,40 @@ local golden = readGolden(GOLDEN) T.check(golden ~= nil, "the committed fixture fingerprint golden exists: " .. GOLDEN) T.eq(actual, golden, "fixture fingerprint matches the committed golden") +-- Section pins make a registry addition reviewable without treating a whole +-- digest change as permission to bless unrelated species or move changes. +local sectionFile = assert(io.open("tests/goldens/fixture_fingerprint_sections.txt", "r")) +local expectedSections = {} +for line in sectionFile:lines() do + local name, expected = line:match("^(%S+)%s+(%x+)$") + if name then expectedSections[name] = expected end +end +sectionFile:close() +local sectionCount = 0 +for name, body in Fingerprint.surface(data, {}):gmatch("%[([^%]]+)%]([^%[]*)") do + sectionCount = sectionCount + 1 + T.eq(Fingerprint.digest("[" .. name .. "]" .. body), expectedSections[name], + "audited fixture surface section: " .. name) + expectedSections[name] = nil +end +T.eq(sectionCount, 7, "the fixture has seven populated fingerprint sections") +T.eq(next(expectedSections), nil, "all committed section pins were checked") + +-- Platinum's four dynamic-power effects were added in 3252aec. They are +-- native full handlers, and removing any one must change link compatibility. +local sinnoh = { + "POWER_BASED_ON_LOW_SPEED", "INCREASE_POWER_WITH_MORE_HP", + "DOUBLE_POWER_WHEN_BELOW_HALF", "DOUBLE_POWER_HEAL_SLEEP", +} +for _, id in ipairs(sinnoh) do + T.eq(data.move_effects[id].kind, "full", "Sinnoh effect is registered: " .. id) + local copy = T.fixtures.fresh() + local copyRun = T.sdk.loadNone({ data = copy }) + copy.move_effects[id] = nil + T.neq(Fingerprint.compute(copy, {}), actual, "omitting " .. id .. " changes compatibility") + copyRun.release() +end + -- determinism: same data, same digest. A fingerprint that folded a table -- address or a pairs() order would pass once and fail in CI. T.eq(Fingerprint.compute(data, {}), actual, "the fingerprint is stable within a process") diff --git a/tests/engine/public_battle_hud_hook.lua b/tests/engine/public_battle_hud_hook.lua new file mode 100644 index 000000000..b4a9f7b69 --- /dev/null +++ b/tests/engine/public_battle_hud_hook.lua @@ -0,0 +1,31 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local run = T.sdk.loadMods({ "mods/hud_probe" }, { fs = T.sdk.memfs({ + ["mods/hud_probe/manifest.json"] = '{"id":"hud_probe","name":"HUD probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ["mods/hud_probe/main.lua"] = [[ + local mod = ... + mod.exports.calls = 0 + mod.hooks:wrap("battle.status_hud_visible", function(nextFn, battle) + mod.exports.calls = mod.exports.calls + 1 + mod.exports.battle = battle + mod.exports.native = nextFn(battle) + return false + end) + ]], +}) }) +T.eq(#run.errors, 0, "HUD owner loads through public API") +local battle = { data = {}, game = { data = {} } } +local Gen4 = require("src.battle.Gen4Battle") +local draws, draw = 0, love.graphics.draw +love.graphics.draw = function() draws = draws + 1 end +T.eq(Gen4.drawHealthboxes(battle), true, "owned HUD prevents fallback native panels") +T.eq(draws, 0, "hidden native HUD performs no draw") +local exports = run.loader.exports.hud_probe +T.eq(exports.calls, 1, "native Gen4 healthbox entry consults the public hook") +T.eq(exports.battle, battle, "hook receives the actual battle") +T.eq(exports.native, true, "unwrapped native visibility defaults to true") +run.loader.hooks:removeOwner("hud_probe") +T.eq(Gen4.drawHealthboxes(battle), false, "without owner, unavailable native art returns to ordinary fallback") +love.graphics.draw = draw +run.release() +T.finish("Public battle HUD ownership hook") diff --git a/tests/engine/public_field_events.lua b/tests/engine/public_field_events.lua new file mode 100644 index 000000000..ba47a15b0 --- /dev/null +++ b/tests/engine/public_field_events.lua @@ -0,0 +1,82 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V = require("src.core.GameVersion") +V.set("red") -- ROM-free engine fixture, no external checkout or game process. +local run = T.sdk.loadMods({ "mods/field_probe" }, { 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 = ... + mod.exports.ticks, mod.exports.blocks, mod.exports.eggs = {}, {}, {} + mod.exports.stopTicks = mod.events:on("world.tick", function(ev) table.insert(mod.exports.ticks, ev) end) + mod.events:on("world.blocks_replaced", function(ev) table.insert(mod.exports.blocks, ev) end) + mod.events:on("pokemon.egg_hatched", function(ev) table.insert(mod.exports.eggs, ev) end) + ]], +}) }) +T.eq(#run.errors, 0, "field observer loads through public API") +local exports = run.loader.exports.field_probe +local Game = require("src.core.Game") +local World = require("src.world.OverworldController") +Game.data, Game.save = run.data, require("src.core.SaveData").newGame() +Game.stack = { states = {} } +function Game.stack:push(state) self.states[#self.states + 1] = state end +function Game.stack:pop() return table.remove(self.states) end +function Game.stack:top() return self.states[#self.states] end +-- Enter the real state without importing or loading a map. The event driver +-- supplies resident geometry and isolates unrelated timed field mechanics. +local world = setmetatable({ setMap = function() end, refreshStandingOnWarp = function() end }, { __index = World }) +world:enter("FIX_TOWN", 0, 0, "down") +local rebuilds = 0 +local map = require("src.world.Map").new({ id = "FIX_TOWN", width = 2, height = 2, + blocks = { 1, 2, 3, 4 } }, { blockTiles = 2, blockCells = 1 }) +map.renderer = { rebuild = function() rebuilds = rebuilds + 1 end } +world.map = map +T.eq(world:replaceBlocks({ 1, 5, 6, 4, 999 }), true, "native bulk change applies within map bounds") +T.eq(#exports.blocks, 1, "one bulk change emits one event") +T.eq(exports.blocks[1].mapId, "FIX_TOWN", "bulk event identifies map") +T.eq(exports.blocks[1].count, 2, "bulk event counts actual changed cells") +T.eq(map:blockAt(1, 0), 5, "bulk event follows committed geometry") +T.eq(rebuilds, 1, "geometry redraw still occurs") +T.eq(world:replaceBlocks({ 1, 5, 6, 4 }), false, "identical replacement is a no-op") +T.eq(#exports.blocks, 1, "no event for unchanged blocks") +local noop = function() end +for _, name in ipairs({ "tickCrackedFloor", "tickPacifidlogLogs", "tickPcScreen", "tickGymDoorSlide", + "tickGen3GateSwing", "tickLavaridgeLaunch", "drainPendingScripts", "noteCellChange", + "tickCyclingChallenge", "watchStuckScript", "applyForcedBike", "checkSpecialPhoneCall", "checkBugContestClock" }) do + world[name] = noop +end +world.runner = { isRunning = function() return true end, update = noop } +local boundary = {} +world.updateParallel = function() error(boundary) end -- stop immediately after the tested event boundary +local ok, reason = pcall(world.update, world, 1/60) +T.eq(ok, false, "driver reaches the post-tick boundary") +T.eq(reason, boundary, "the native update reached the event without another failure") +T.eq(#exports.ticks, 1, "one native update emits one fixed tick") +T.eq(exports.ticks[1].dt, 1/60, "tick supplies the fixed dt") +T.eq(exports.ticks[1].mapId, "FIX_TOWN", "tick identifies the resident map") +T.eq(exports.ticks[1].overworld, world, "tick supplies the real world") +T.eq(exports.ticks[1].scripted, true, "tick reports the native runner state") +-- Hot-path parity: without listeners the actual update never calls emit. +exports.stopTicks() +local Runtime = require("src.mods.Runtime") +local originalEmit, tickEmits = Runtime.emit, 0 +Runtime.emit = function(name, ...) + if name == "world.tick" then tickEmits = tickEmits + 1 end + return originalEmit(name, ...) +end +local _, noListenerBoundary = pcall(world.update, world, 1/60) +Runtime.emit = originalEmit +T.eq(noListenerBoundary, boundary, "unsubscribed tick reaches the same native boundary") +T.eq(tickEmits, 0, "unsubscribed hot tick does not emit or build its payload") +local mon = require("src.pokemon.Pokemon").new(run.data, "FIXMON_A", 5) +mon.isEgg, mon.eggSteps, mon.eggCycles, mon.nickname = true, 9, 2, "EGG" +T.eq(world:hatchEgg(mon), true, "native hatch accepts the egg") +T.eq(mon.isEgg, nil, "native hatch clears egg flag") +T.eq(mon.eggSteps, nil, "native hatch clears egg steps") +T.eq(mon.eggCycles, nil, "native hatch clears egg cycles") +T.eq(#exports.eggs, 0, "hatch event waits for the message completion") +local box = Game.stack:top() +assert(box.onDone, "real native hatch message has a completion callback")() +T.eq(#exports.eggs, 1, "message completion emits one hatch event") +T.eq(exports.eggs[1].mon, mon, "hatch observer receives the normalized Pokemon") +run.release() +T.finish("Public field and egg events") diff --git a/tests/engine/public_intro_events.lua b/tests/engine/public_intro_events.lua new file mode 100644 index 000000000..708bbfde5 --- /dev/null +++ b/tests/engine/public_intro_events.lua @@ -0,0 +1,43 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +require("src.core.GameVersion").set("emerald") +local run = T.sdk.loadMods({ "mods/intro_probe" }, { fs = T.sdk.memfs({ + ["mods/intro_probe/manifest.json"] = '{"id":"intro_probe","name":"Intro probe","version":"1.0.0","api":2,"entry":"main.lua","games":["gen3"]}', + ["mods/intro_probe/main.lua"] = [[ + local mod = ... + mod.exports.started, mod.exports.finished = {}, {} + mod.events:on("intro.birch_speech.started", function(ev) + table.insert(mod.exports.started, ev) + end) + mod.events:on("intro.birch_speech.finished", function(ev) + table.insert(mod.exports.finished, ev) + end) + ]], +}) }) +T.eq(#run.errors, 0, "intro observer loads through public API") +local stack = { states = {} } +function stack:push(s) self.states[#self.states + 1] = s end +function stack:pop() return table.remove(self.states) end +local game = { data = run.data, save = { player = {} }, stack = stack } +local done = false +local exports = run.loader.exports.intro_probe +local speech = require("src.ui.BirchSpeech").new(game, function() + T.eq(#exports.finished, 1, "finish event precedes completion callback") + done = true +end) +stack:push(speech) +speech:enter() +T.eq(#exports.started, 1, "native intro enter emits start once") +T.eq(exports.started[1].speech, speech, "start identifies the actual speech") +T.eq(#exports.finished, 0, "enter does not finish the intro") +-- Exercise the native finish boundary independently of keyboard/name entry. +speech.answers = { gender = "girl", name = "MAY" } +stack:pop() -- the skipped question is closed before the native finish boundary +speech:finish() +T.eq(#exports.finished, 1, "native finish emits completion once") +T.eq(exports.finished[1].speech, speech, "finish identifies the same speech") +T.same(exports.finished[1].answers, speech.answers, "finish exposes the intro answers") +T.eq(done, true, "completion callback still runs") +T.eq(#stack.states, 0, "native finish removes the intro from the stack") +run.release() +T.finish("Public Birch intro events") diff --git a/tests/engine/quit_thread_shutdown.lua b/tests/engine/quit_thread_shutdown.lua index a0dff35be..02c0f7aa1 100644 --- a/tests/engine/quit_thread_shutdown.lua +++ b/tests/engine/quit_thread_shutdown.lua @@ -164,7 +164,7 @@ local function source(path) check(f ~= nil, path .. " is readable") local text = f and f:read("*a") or "" if f then f:close() end - return text + return (text:gsub("\r\n", "\n")) end local chipSrc = source("src/core/chip_worker.lua") @@ -192,18 +192,77 @@ check(quitHook:find('package.loaded["src.update.Check"].shutdown', 1, true) ~= n -- so the quit event exits the process outright. It has to sit after the -- love.quit() veto test, or the editor's abort-quit path would die on a quit -- it refused. -local quitBranch = mainSrc:match('if name == "quit" then(.-)\n%s*end\n') -check(quitBranch ~= nil, "love.run still handles the quit event") -quitBranch = quitBranch or "" -local vetoAt = quitBranch:find("not love%.quit()") -local osAt = quitBranch:find("os%.exit") -local androidAt = quitBranch:find('getOS() == "Android"', 1, true) -check(vetoAt ~= nil and osAt ~= nil and vetoAt < osAt, - "the process exit runs only after love.quit declined to veto") -check(androidAt ~= nil and androidAt < osAt, - "the process exit is gated on Android") -local exits = 0 -for _ in mainSrc:gmatch("os%.exit") do exits = exits + 1 end -eq(exits, 1, "os.exit appears once, at the quit event") +-- Execute the actual run-loop function in an isolated environment. Scanning +-- nested `end`s or counting os.exit in comments cannot prove veto behavior. +local runAt = assert(mainSrc:find("\nfunction love.run()", 1, true)) +local runSource = mainSrc:sub(runAt + 1) +local function quitFrame(platform, answer, code, noCallback) + local calls, exits = {}, {} + local function record(name) calls[#calls + 1] = name end + local env = setmetatable({ + BootTrace = { mark = function() end, frame = function() record("frame") end }, + Frames = { tick = function() record("tick") end }, + FrameProfile = { section = function() return function() end end }, + pacingEnabled = function() return false end, + os = { exit = function(value) record("exit"); exits[#exits + 1] = value end }, + love = { + handlers = { quit = function() record("dispatch") end }, + update = function() record("update") end, + event = { + pump = function() end, + poll = function() + local sent = false + return function() + if not sent then sent = true; return "quit", code end + end + end, + }, + }, + }, { __index = _G }) + if platform then env.love.system = { getOS = function() return platform end } end + if not noCallback then + env.love.quit = function() record("quit"); return answer end + end + local compile = loadstring or load + local chunk + if setfenv then + chunk = assert(compile(runSource, "@main.lua:love.run")) + setfenv(chunk, env) + else + chunk = assert(load(runSource, "@main.lua:love.run", "t", env)) + end + chunk() + local result = env.love.run()() + return result, table.concat(calls, ","), exits +end + +for _, platform in ipairs({ "Android", "Windows", "OS X", "Linux", "NX", false }) do + local label = tostring(platform) + for _, code in ipairs({ 0, 7, false }) do + -- false is a table-friendly sentinel for an omitted exit status. + local expected = code or 0 + for _, noCallback in ipairs({ false, true }) do + for _, answer in ipairs({ false, "nil" }) do + local callbackAnswer = answer + if answer == "nil" then callbackAnswer = nil end + local result, order, exits = quitFrame(platform, callbackAnswer, code or nil, noCallback) + local prefix = noCallback and "" or "quit" + eq(result, expected, label .. ": accepted quit returns its exit status") + if platform == "Android" then + eq(#exits, 1, "Android exits exactly once after accepting quit") + eq(exits[1], expected, "Android forwards the requested exit status") + eq(order, prefix == "" and "exit" or "quit,exit", "shutdown precedes process exit") + else + eq(#exits, 0, label .. ": accepted quit does not force a process exit") + eq(order, prefix, label .. ": no further frame work after accepted quit") + end + end + end + end + local result, order, exits = quitFrame(platform, true, 7) + eq(result, nil, label .. ": veto keeps the frame running") + eq(#exits, 0, label .. ": veto never exits the process") + eq(order, "quit,dispatch,update,frame,tick", label .. ": veto resumes event and frame processing") +end T.finish("quit thread shutdown") diff --git a/tests/engine/rebind_capture_bug510.lua b/tests/engine/rebind_capture_bug510.lua index e5d13d311..f36306d15 100644 --- a/tests/engine/rebind_capture_bug510.lua +++ b/tests/engine/rebind_capture_bug510.lua @@ -48,8 +48,13 @@ local function openMenu(game) return bm end --- rows are BindingsMenu's BUTTONS order; 5 = A, 6 = B -local ROW_A, ROW_B = 5, 6 +-- Find actions by their public menu row metadata, not by a generation's order. +local function row(menu, action) + for i, item in ipairs(menu.items) do + if item.button and item.button.id == action then return i end + end + error("missing controls row: " .. action) +end -- The mechanism, pinned so a future "just apply it immediately" revert -- fails here: a rebind overwrites whatever the key used to do, so B = Z @@ -73,14 +78,14 @@ check(game.save.options.bindings == nil, "escaping a capture writes no binding") eq(game.wroteOptions, 0, "and does not touch options on disk") -bm.index = ROW_B +bm.index = row(bm, "b") press(bm, "a") bm:onKeyPressed("z") check(game.save.options.bindings == nil, "a capture holds its press; nothing stores before the release (#589)") bm:onKeyReleased("z") eq(game.save.options.bindings.b.key, "z", "releasing the press stores B = Z") -eq(bm.items[ROW_B].right, "Z/B", "the row shows the new key straight away") +eq(bm.items[row(bm, "b")].right, "Z/B", "the row shows the new key straight away") eq(game.wroteOptions, 1, "the choice persists immediately") eq(Input.keyBindings["z"], "a", "but the live map still reads Z as A while the screen is open (#510)") @@ -90,12 +95,12 @@ eq(#game.stack.states, 1, "capturing Z does not close the screen") -- inherits B's previous key and no key serves two rows (#589) eq(game.save.options.bindings.a.key, "x", "capturing A's key for B hands A the old B key") -eq(bm.items[ROW_A].right, "X/A", "and the A row redraws with it") +eq(bm.items[row(bm, "a")].right, "X/A", "and the A row redraws with it") -- the next Z the player presses is still confirm, so the A row can be armed -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") -eq(bm.capture, bm.items[ROW_A], "the A row arms instead of the screen closing") +eq(bm.capture, bm.items[row(bm, "a")], "the A row arms instead of the screen closing") bm:onKeyPressed("x") bm:onKeyReleased("x") eq(game.save.options.bindings.a.key, "x", "re-capturing A's own key keeps it") @@ -117,16 +122,18 @@ eq(Input.keyBindings["z"], "b", "a reload reaches the same map as the close") Input:init() local padGame = newGame() local padBm = openMenu(padGame) -padBm.index = ROW_B +padBm.index = row(padBm, "b") press(padBm, "a") padBm:onGamepadPressed("y") padBm:onGamepadReleased("y") eq(padGame.save.options.bindings.b.pad, "y", "a pad capture stores") -eq(Input.padBindings["y"], nil, "and stays out of the live pad map until close") +eq(Input.padBindings["y"], "x", "pad Y keeps its default DS X action until close") +eq(padGame.save.options.bindings.x.pad, "b", "DS X inherits the captured row's pad B") +eq(Input.padBindings["b"], "b", "the donor swap is also deferred until close") -- a second input going down while the first is held backs the capture out -- with no keyboard in reach, the pad's Escape (#589) -padBm.index = ROW_A +padBm.index = row(padBm, "a") press(padBm, "a") padBm:onGamepadPressed("x") padBm:onGamepadPressed("b") @@ -139,6 +146,20 @@ eq(padGame.save.options.bindings.a, nil, press(padBm, "b") eq(Input.padBindings["y"], "b", "closing commits the pad half too") +eq(Input.padBindings["b"], "x", "closing commits the DS X donor half") + +-- A genuinely unbound input must also remain absent until the screen closes. +Input:init() +local spareGame = newGame() +local spareBm = openMenu(spareGame) +spareBm.index = row(spareBm, "a") +press(spareBm, "a") +spareBm:onGamepadPressed("leftstick") +spareBm:onGamepadReleased("leftstick") +eq(spareGame.save.options.bindings.a.pad, "leftstick", "unbound pad capture stores") +eq(Input.padBindings.leftstick, nil, "unbound pad remains absent while the menu is open") +press(spareBm, "b") +eq(Input.padBindings.leftstick, "a", "closing activates the newly bound pad") Input:init() T.finish("rebind_capture_bug510") diff --git a/tests/engine/rebind_swap_clear_bug589.lua b/tests/engine/rebind_swap_clear_bug589.lua index 0e49d03d0..7ccca5fe0 100644 --- a/tests/engine/rebind_swap_clear_bug589.lua +++ b/tests/engine/rebind_swap_clear_bug589.lua @@ -54,8 +54,12 @@ local function settleChoice(state) for _ = 1, Timing.YES_NO_ANSWER do state:update(1 / 60) end end --- rows are BindingsMenu's BUTTONS order -local ROW_A, ROW_B, ROW_SELECT = 5, 6, 8 +local function row(menu, action) + for i, item in ipairs(menu.items) do + if item.button and item.button.id == action then return i end + end + error("missing controls row: " .. action) +end -- ---- (a) pad capture swaps with the row that owns the button -------------- @@ -64,9 +68,9 @@ local game = newGame() local bm = BindingsMenu.new(game) game.stack:push(bm) -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") -eq(bm.capture, bm.items[ROW_A], "A arms the A row") +eq(bm.capture, bm.items[row(bm, "a")], "A arms the A row") bm:onGamepadPressed("b") check(game.save.options.bindings == nil, "a pad capture holds its press; nothing stores before the release") @@ -75,8 +79,8 @@ local bindings = game.save.options.bindings eq(bindings.a.pad, "b", "releasing pad B stores it on the A row") eq(bindings.b.pad, "a", "and the B row, which owned pad B, inherits the A row's old pad (#589)") -eq(bm.items[ROW_A].right, "Z/B", "the A row redraws with the new pad") -eq(bm.items[ROW_B].right, "X/A", "so does the B row") +eq(bm.items[row(bm, "a")].right, "Z/B", "the A row redraws with the new pad") +eq(bm.items[row(bm, "b")].right, "X/A", "so does the B row") -- after applying, every row's effective pad is unique and none is lost Input:applyBindings(bindings) @@ -89,13 +93,21 @@ for button, action in pairs(Input.padBindings) do seen[action] = button actions[#actions + 1] = action end -eq(#actions, 8, "all eight actions still have exactly one pad button") +eq(#actions, 12, "all twelve actions still have exactly one pad button") +local unchanged = { + up = "dpup", down = "dpdown", left = "dpleft", right = "dpright", + x = "y", y = "x", start = "start", select = "back", + l = "leftshoulder", r = "rightshoulder", +} +for action, button in pairs(unchanged) do + eq(seen[action], button, "A/B swap preserves the binding for " .. action) +end Input:init() -- ---- (b) a second input while the first is held cancels ------------------- -- key then key: the straggling release of the first press writes nothing -bm.index = ROW_SELECT +bm.index = row(bm, "select") press(bm, "a") bm:onKeyPressed("q") bm:onKeyPressed("w") @@ -121,17 +133,17 @@ check(bindings.select == nil, "press alone commits nothing (#589)") eq(game.wroteOptions, writesAfterSwap, "and touches nothing on disk") bm:onKeyReleased("q") eq(bindings.select.key, "q", "the release is the commit") -eq(bm.items[ROW_SELECT].right, "Q/BACK", "the row shows the new key") +eq(bm.items[row(bm, "select")].right, "Q/BACK", "the row shows the new key") -- ---- (d) SELECT on a row forgets its rebind ------------------------------- press(bm, "select") check(bindings.select == nil, "SELECT drops the row's overlay entry (#589)") -eq(bm.items[ROW_SELECT].right, "TAB/BACK", "the row falls back to the default") +eq(bm.items[row(bm, "select")].right, "TAB/BACK", "the row falls back to the default") -- the swapped B row clears the same way; a second SELECT on the now -- default row is a no-op, not a write local writes = game.wroteOptions -bm.index = ROW_B +bm.index = row(bm, "b") press(bm, "select") eq(game.wroteOptions, writes + 1, "clearing the swapped B row writes once") check(game.save.options.bindings.b == nil, "and drops its overlay entry") @@ -141,7 +153,7 @@ eq(game.wroteOptions, writes + 1, "clearing an already-default row writes nothin -- ---- (e) START confirms, then drops the whole overlay --------------------- -- rebuild a dirty overlay to reset -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") bm:onKeyPressed("p") bm:onKeyReleased("p") @@ -158,7 +170,7 @@ press(box, "a") settleChoice(box) eq(game.stack:top(), bm, "answering pops the box") check(game.save.options.bindings ~= nil, "NO keeps the bindings (defaultNo)") -eq(bm.items[ROW_A].right, "P/B", "and the rows keep showing them") +eq(bm.items[row(bm, "a")].right, "P/B", "and the rows keep showing them") -- again, flip to YES: the overlay goes away and every row reads default press(bm, "start") @@ -167,8 +179,8 @@ press(box, "up") press(box, "a") settleChoice(box) check(game.save.options.bindings == nil, "YES clears options.bindings (#589)") -eq(bm.items[ROW_A].right, "Z/A", "the A row reads its default again") -eq(bm.items[ROW_B].right, "X/B", "so does the B row the swap had touched") +eq(bm.items[row(bm, "a")].right, "Z/A", "the A row reads its default again") +eq(bm.items[row(bm, "b")].right, "X/B", "so does the B row the swap had touched") -- closing after the reset leaves the live map at the defaults press(bm, "b") diff --git a/tests/engine/soft_reset_bug563.lua b/tests/engine/soft_reset_bug563.lua index 65fb22445..d1fa395c7 100644 --- a/tests/engine/soft_reset_bug563.lua +++ b/tests/engine/soft_reset_bug563.lua @@ -106,18 +106,35 @@ check(stepChord(), "reaching 16 again from the restart") -- before stack:update, so it fires from a battle, a menu or a cutscene and -- not just from the overworld (#563). local function fakeGame() - local game = { input = Input, save = {}, returned = 0 } + local game = setmetatable({ input = Input, save = {}, returned = 0 }, { __index = Game }) game.stack = { + states = { {} }, -- a live state, so the real empty-stack guard does not recover updates = 0, update = function(self) self.updates = self.updates + 1 end, } function game:returnToTitle() self.returned = self.returned + 1 end - return game + return setmetatable(game, { __index = Game }) end local game = fakeGame() holdKeys("z", "x", "escape", "tab") +-- Observe the real profiling wrapper as well as its real _step body. A reset +-- returns early from _step, but must still close the outer profiling scope. +local FrameProfile = require("src.core.FrameProfile") +local savedSection = FrameProfile.section +local opened, closed = 0, 0 +FrameProfile.section = function(name) + local close = savedSection(name) + if name == " step: whole" then opened = opened + 1 end + return function() + if name == " step: whole" then closed = closed + 1 end + close() + end +end for _ = 1, HOLD_STEPS do Game.step(game, 1 / 60) end +FrameProfile.section = savedSection +eq(opened, HOLD_STEPS, "every logic step enters the real profiling wrapper") +eq(closed, HOLD_STEPS, "the reset early return closes its profiling scope") eq(game.returned, 1, "Game:step falls back to the title on the 16th step") eq(game.stack.updates, HOLD_STEPS - 1, "and the state on top never gets that step, whatever it was") @@ -131,16 +148,17 @@ local bare = fakeGame() bare.input = { step = function() end, isDown = function() return false end } for _ = 1, HOLD_STEPS * 2 do Game.step(bare, 1 / 60) end eq(bare.returned, 0, "an input with no chord bookkeeping never resets") +eq(bare.stack.updates, HOLD_STEPS * 2, "an input stand-in still updates the real state stack") -- ---- the on-screen overlay --------------------------------------------- -- Force the overlay live the way a phone would have it. img is only ever --- tested for truthiness on the input path (drawing is what reads it), so a --- placeholder keeps this suite off love.graphics.newImage. +-- checked per button on the input path. Provide each available control +-- without asking love.graphics to load actual image assets. Input:init() TouchControls:init() TouchControls.active, TouchControls.enabled = true, true -TouchControls.img = { stub = true } +TouchControls.img = { a = {}, b = {}, start = {}, select = {}, dpad = {} } local L = TouchControls:layout() -- One finger, one control: touchpressed returns on its first hit, so no diff --git a/tests/engine/tool_mod_hooks.lua b/tests/engine/tool_mod_hooks.lua index 36f1f2a01..6845cad9e 100644 --- a/tests/engine/tool_mod_hooks.lua +++ b/tests/engine/tool_mod_hooks.lua @@ -8,6 +8,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local Hooks = require("src.mods.Hooks") local Runtime = require("src.mods.Runtime") +local Game = require("src.core.Game") local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks, Runtime.errors @@ -19,14 +20,14 @@ Runtime.hooks = hooks -- ordering rather than merely checking that a hook was registered. do local order = {} - local fake = { + local fake = setmetatable({ input = { step = function() order[#order + 1] = "input" end }, - stack = { update = function(_, dt) + stack = { states = { {} }, update = function(_, dt) order[#order + 1] = "world" T.eq(dt, 1 / 60, "the world receives the fixed-step dt") end }, save = {}, - } + }, { __index = Game }) hooks:wrap("input.step", function(nextFn, game, dt) T.check(game == fake, "input.step receives the live Game object") T.eq(dt, 1 / 60, "input.step receives the fixed-step dt") @@ -34,7 +35,9 @@ do return nextFn(game, dt) end, 0, "tool_fixture") - require("src.core.Game").step(fake, 1 / 60) + local Game = require("src.core.Game") + setmetatable(fake, { __index = Game }) + Game.step(fake, 1 / 60) T.eq(table.concat(order, ","), "hook,input,world", "input.step runs before input edges are promoted and before gameplay") hooks:removeOwner("tool_fixture") @@ -71,10 +74,10 @@ end -- the live snapshot in preparation for disk IO. do local captured = false - local fake = { + local fake = setmetatable({ save = {}, overworld = { captureSave = function() captured = true end }, - } + }, { __index = Game }) hooks:wrap("save.write", function(nextFn, game) T.check(game == fake, "save.write receives the live Game object") return false @@ -112,14 +115,14 @@ do -- Game:draw iterates stack.states and calls each state's draw (it no -- longer goes through stack:draw), so the fixture must put a drawable -- state on the stack to observe composition order. - local fake = { + local fake = setmetatable({ overworld = {}, stack = { states = { { draw = function() order[#order + 1] = "states" end }, }, }, - } + }, { __index = Game }) function fake.stack:visibleBase() return 1 end function fake.stack:top() return self.states[#self.states] end @@ -133,7 +136,9 @@ do return nextFn(game, viewport) end, 0, "tool_fixture") - require("src.core.Game").draw(fake) + local Game = require("src.core.Game") + setmetatable(fake, { __index = Game }) + Game.draw(fake) T.eq(table.concat(order, ","), "states,present,hud,touch", "render.hud draws after frame composition and before touch controls") hooks:removeOwner("tool_fixture") diff --git a/tests/engine/update_check_tests.lua b/tests/engine/update_check_tests.lua index 698c5c9f7..0dd82311a 100644 --- a/tests/engine/update_check_tests.lua +++ b/tests/engine/update_check_tests.lua @@ -13,22 +13,22 @@ local Json = require("src.link.Json") -- releaseUrl is the fixed public landing page the UI links on needs_full eq(Check.releaseUrl(), - "https://github.com/bryanthaboi/gen1recomp/releases/latest", + "https://github.com/UNDERdecoded/Gen2Recomped/releases/latest", "releaseUrl points at the repo's latest release") -- parseRelease: a well-formed release with the .love payload and its sums local body = Json.encode({ tag_name = "v1.4.2", assets = { - { name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, - { name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, + { name = "Gen2Recomped-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, + { name = "Gen2Recomped-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, { name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 }, }, }) local rel = Check.parseRelease(body) check(rel ~= nil, "parseRelease accepts a valid release") eq(rel.version, "1.4.2", "leading v stripped from tag_name") -eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version") +eq(rel.payloadName, "Gen2Recomped-1.4.2.love", "payload name derived from version") eq(rel.payload.url, "http://x/love", "payload asset url picked") eq(rel.payload.size, 12345, "payload asset size picked") eq(rel.sums.url, "http://x/sums", "sums asset url picked") @@ -41,6 +41,13 @@ eq(noPayload.version, "2.0.0", "version parsed without assets") eq(noPayload.payload, nil, "no payload asset -> nil") eq(noPayload.sums, nil, "no sums asset -> nil") +-- Assets from the other engine must not be admitted as this engine's update. +local foreign = Check.parseRelease(Json.encode({ tag_name = "v1.4.2", assets = { + { name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/foreign", size = 99 }, +} })) +check(foreign ~= nil, "foreign-asset release metadata is parseable") +eq(foreign.payload, nil, "a Gen1 payload cannot replace a Gen2 runtime") + -- rejects: non-semver tag, and a document with no tag at all local bad, badErr = Check.parseRelease(Json.encode({ tag_name = "nightly" })) eq(bad, nil, "non-X.Y.Z tag rejected") @@ -50,13 +57,13 @@ eq(Check.parseRelease(Json.encode({ foo = 1 })), nil, "missing tag_name rejected -- parseSums: shasum -a 256 format, tolerating the '*' binary marker, a './' -- prefix and CRLF line endings; unrelated lines are skipped local sums = - "aaaa1111 gen1recomp-1.4.2.love\n" .. + "aaaa1111 Gen2Recomped-1.4.2.love\n" .. "BBBB2222 *./sha256sums.txt\r\n" .. "not a checksum line\n" local map = Check.parseSums(sums) -eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "bare-name sum parsed") +eq(map["Gen2Recomped-1.4.2.love"], "aaaa1111", "bare-name sum parsed") eq(map["sha256sums.txt"], "bbbb2222", "* marker and ./ prefix stripped, lowered") -eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash") +eq(Check.parseSums(sums, "Gen2Recomped-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash") eq(Check.parseSums(sums, "missing.love"), nil, "targeted lookup misses cleanly") -- pickAsset guards a non-table assets field diff --git a/tests/engine/update_tests.lua b/tests/engine/update_tests.lua index 9bf67171e..cb8c79337 100644 --- a/tests/engine/update_tests.lua +++ b/tests/engine/update_tests.lua @@ -193,11 +193,11 @@ end -- --------------------------------------------------------------------------- local assets = { - { name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, - { name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, + { name = "Gen2Recomped-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, + { name = "Gen2Recomped-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, { name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 }, } -local picked = Check.pickAsset(assets, "gen1recomp-1.4.2.love") +local picked = Check.pickAsset(assets, "Gen2Recomped-1.4.2.love") check(picked ~= nil, "pickAsset finds an asset by exact name") eq(picked.url, "http://x/love", "pickAsset returns the download url") eq(picked.size, 12345, "pickAsset returns the numeric size") @@ -226,7 +226,7 @@ local body = Json.encode({ local rel = Check.parseRelease(body) check(rel ~= nil, "parseRelease accepts a valid release") eq(rel.version, "1.4.2", "parseRelease strips the leading v from tag_name") -eq(rel.payloadName, "gen1recomp-1.4.2.love", "parseRelease derives the payload name from the version") +eq(rel.payloadName, "Gen2Recomped-1.4.2.love", "parseRelease derives the payload name from the version") eq(rel.payload.url, "http://x/love", "parseRelease picks the payload asset url") eq(rel.payload.size, 12345, "parseRelease picks the payload asset size") eq(rel.sums.url, "http://x/sums", "parseRelease picks the sums asset url") @@ -268,18 +268,18 @@ eq(literal, nil, "parseRelease returns nil on a non-object JSON literal") -- standard " " lines, the '*' binary marker, a './' prefix, CRLF -- endings and mixed hash case; junk lines are skipped local sums = - "aaaa1111 gen1recomp-1.4.2.love\n" .. + "aaaa1111 Gen2Recomped-1.4.2.love\n" .. "BBBB2222 *./sha256sums.txt\r\n" .. "deadBEEF ./nested.love\n" .. "not a checksum line at all\n" local map = Check.parseSums(sums) -eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "parseSums reads a bare-name line") +eq(map["Gen2Recomped-1.4.2.love"], "aaaa1111", "parseSums reads a bare-name line") eq(map["sha256sums.txt"], "bbbb2222", "parseSums strips the * marker and ./ prefix and lowercases") eq(map["nested.love"], "deadbeef", "parseSums lowercases a mixed-case hash and strips ./") eq(map["not a checksum line at all"], nil, "parseSums skips lines that are not checksums") -- the target form returns just that file's hash (hit / miss) -eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "parseSums(target) returns the matching hash") +eq(Check.parseSums(sums, "Gen2Recomped-1.4.2.love"), "aaaa1111", "parseSums(target) returns the matching hash") eq(Check.parseSums(sums, "missing.love"), nil, "parseSums(target) misses cleanly on an unknown file") -- degenerate inputs: empty text yields an empty map, a targeted miss is nil, @@ -294,7 +294,7 @@ eq(Check.parseSums(nil, "anything"), nil, "parseSums(nil, target) returns nil wi -- --------------------------------------------------------------------------- eq(Check.releaseUrl(), - "https://github.com/bryanthaboi/gen1recomp/releases/latest", + "https://github.com/UNDERdecoded/Gen2Recomped/releases/latest", "releaseUrl points at the repo's latest release") T.finish("update") diff --git a/tests/engine/wide_battle_layout.lua b/tests/engine/wide_battle_layout.lua index 5571af399..804a7f816 100644 --- a/tests/engine/wide_battle_layout.lua +++ b/tests/engine/wide_battle_layout.lua @@ -38,13 +38,44 @@ T.eq(WideBattle.navigate(1, 4, pressing("right")), 2, T.eq(WideBattle.navigate(1, 4, pressing("a")), nil, "navigate leaves A / B / SELECT to the battle engine") --- animation frames shift as one rigid group toward the side they play on -local px, py = WideBattle.animationOffset({ { x = 24 }, { x = 64 } }) -T.eq(px, 20, "a player-side frame lands on the player anchor") -T.eq(py, 8, "a player-side frame lands on the player baseline") -local ex, ey = WideBattle.animationOffset({ { x = 104 }, { x = 144 } }) -T.eq(ex, 136, "an enemy-side frame lands on the enemy anchor") -T.eq(ey, 0, "an enemy-side frame lands on the enemy baseline") +-- OAM x is the right edge of an 8px sprite. These groups are centered on +-- the authored player (26) and enemy (124) anchors, rather than merely +-- falling somewhere on their half of the 160px screen. +local px, py = WideBattle.animationOffset({ { x = 14 }, { x = 46 } }) +T.eq(px, 20, "the player anchor maps to its wide-layout offset") +T.eq(py, 8, "the player anchor maps to its wide-layout baseline") +local ex, ey = WideBattle.animationOffset({ { x = 112 }, { x = 144 } }) +T.eq(ex, 136, "the enemy anchor maps to its wide-layout offset") +T.eq(ey, 0, "the enemy anchor maps to its wide-layout baseline") + +local function offset(x, wantX, wantY, label) + local dx, dy = WideBattle.animationOffset({ { x = x } }) + T.eq(dx, wantX, label .. " horizontal offset") + T.eq(dy, wantY, label .. " vertical offset") +end +offset(30, 20, 8, "single sprite at the player anchor") +offset(128, 136, 0, "single sprite at the enemy anchor") +offset(79, 78, 4, "midpoint between anchors") +-- Wobble must continue across the anchor instead of sticking at it. +offset(29, 19, 8, "one pixel before the player anchor") +offset(31, 21, 8, "one pixel after the player anchor") +offset(127, 135, 0, "one pixel before the enemy anchor") +offset(129, 137, 0, "one pixel after the enemy anchor") +-- Bounded overshoot lets balls leave the picture without unbounded offsets. +offset(-1000, -3, 10, "far left overshoot") +offset(1000, 159, -2, "far right overshoot") +local rx, ry = WideBattle.animationOffset({ { x = 46 }, { x = 14 } }) +T.eq(rx, px, "sprite ordering does not change the rigid frame offset") +T.eq(ry, py, "sprite ordering does not change the baseline") +local emptyX, emptyY = WideBattle.animationOffset({}) +T.eq(emptyX, 0, "an empty frame has no horizontal offset") +T.eq(emptyY, 0, "an empty frame has no vertical offset") + +local mx, my = WideBattle.animationOffset({ { x = 79 } }) +T.eq(mx, 78, "a crossing frame interpolates halfway between picture anchors") +T.eq(my, 4, "the crossing baseline interpolates with the same fraction") +local ox = WideBattle.animationOffset({ { x = 20 } }) +T.check(ox < 20, "motion can overshoot the player anchor without the old 40px clamp") -- the surface: a request outside the bounds falls back to the GB screen, -- and the canvas is only reallocated when the size actually changes diff --git a/tests/goldens/fixture_fingerprint.md b/tests/goldens/fixture_fingerprint.md new file mode 100644 index 000000000..d8cc8e062 --- /dev/null +++ b/tests/goldens/fixture_fingerprint.md @@ -0,0 +1,36 @@ +# Fixture link fingerprint audit + +The previous `7de3051a7b3c108e` pin was already stale at Gen2Recomped's +`0b8c9c4` import: that checkout computes `8c94d595cd60ca69` against its own +ROM-free fixtures. It is not a baseline that current Gen2 data can satisfy. + +At upstream `13af468b97dc37015d14ad5c71d7f067c3ee810d`, the same production +serializer and digest compute `41e69caf4afa35ab`. Comparing the canonical +`Fingerprint.surface(data, {})` strings against `0b8c9c4` changes **only** +`move_effects`: commit `3252aec` adds these four full-handler registry records: + +- `POWER_BASED_ON_LOW_SPEED` (Gyro Ball) +- `INCREASE_POWER_WITH_MORE_HP` (Wring Out / Crush Grip) +- `DOUBLE_POWER_WHEN_BELOW_HALF` (Brine) +- `DOUBLE_POWER_HEAL_SLEEP` (Wake-Up Slap) + +No records were removed or altered in the serialized surface comparison. +The current surface has 198 effect records. Species, moves, type chart, +statuses, constants and mod list are byte-identical to the imported checkout. +An independent Python implementation using integer XOR, multiplication by +16777619 and modulo 2**32 in both lanes also produces the current digest. +No fingerprint algorithm, allowlist, cache or production registry is changed +by this test repair. + +`fixture_fingerprint_sections.txt` pins each populated section separately so +future intentional registry additions cannot hide changes in another section. +`gate_fingerprint.lua` retains all prior mutation and determinism assertions, +and additionally proves that removing any of the four Sinnoh registrations +changes compatibility. An empty `link_fields` registry has no serialized +section; a future nonempty one requires a deliberate new section pin. + +To audit a future change, load a fresh `tests.modkit` fixture with +`T.sdk.loadNone({data=data})`, compare `Fingerprint.surface(data,{})` section +by section, and then update only reviewed pins. Run the gate again and keep +the mutation assertions enabled. The ROM-dependent vanilla golden is separate +and was not re-blessed by this update. diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index 3ab0752dc..77ad32cd7 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -7de3051a7b3c108e +41e69caf4afa35ab diff --git a/tests/goldens/fixture_fingerprint_sections.txt b/tests/goldens/fixture_fingerprint_sections.txt new file mode 100644 index 000000000..2e9081fa0 --- /dev/null +++ b/tests/goldens/fixture_fingerprint_sections.txt @@ -0,0 +1,7 @@ +pokemon 0ad4d8a59c0daa59 +moves 54b3cb2c1776ed48 +type_chart cc5bd80d003bd459 +statuses 68247ced2ed19a21 +move_effects 3bef37494ca4a845 +constants e4cf6f91ff122fa5 +mods 6a9ee9de68f78902 From 1aaeb40b2e00c644606e080cc2e0baa02ad2daed Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:16:12 +0100 Subject: [PATCH 71/71] Count actual host process calls instead of explanatory comments --- tests/parity_picker_pointer_grab.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/parity_picker_pointer_grab.lua b/tests/parity_picker_pointer_grab.lua index 6b5e56bcb..abb35380b 100644 --- a/tests/parity_picker_pointer_grab.lua +++ b/tests/parity_picker_pointer_grab.lua @@ -69,7 +69,8 @@ do -- the punctuation is the point: `io.popen(` or `io.popen,` is a CALL, -- and the prose above it in that file mentions the name twice local calls = 0 - for _ in host:gmatch("io%.popen[,(]") do calls = calls + 1 end + local code = host:gsub("%-%-[^\n]*", "") + for _ in code:gmatch("io%.popen[,(]") do calls = calls + 1 end eq(calls, 1, "and the whole engine holds exactly one io.popen, in " .. "HostShell -- which is what makes 'every picker funnels through " .. "commandOutput' a statement about all of them")